diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml new file mode 100644 index 00000000..c8d99012 --- /dev/null +++ b/.github/workflows/cd-dev.yml @@ -0,0 +1,212 @@ +name: CD - Deploy to Dev Server + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: [self-hosted, dev-runner] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant permission to gradlew + run: chmod +x ./gradlew + + - name: Generate application.yml + run: | + mkdir -p src/main/resources + cat < src/main/resources/application.yml + server: + port: ${SERVER_PORT:8080} + security: + jwt: + token: + expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }} + secret-key: ${{ secrets.JWT_SECRET_KEY }} + admin: + password: ${{ secrets.ADMIN_PASSWORD }} + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: code-l-bucket + credentials: + access-key: ${{ secrets.DEV_S3_ACCESS_KEY }} + secret-key: ${{ secrets.DEV_S3_SECRET_KEY }} + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + processor : false + spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${{ secrets.DEV_RDS_URL }} + username: ${{ secrets.DEV_RDS_USER_NAME }} + password: ${{ secrets.DEV_RDS_USER_PASSWORD }} + hikari : + maximum-pool-size : 20 + minimum-idle : 10 + connection-timeout: 10000 + max-lifetime: 180000 + + servlet: + multipart: + max-file-size: 20MB + max-request-size: 40MB + + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + show-sql: false + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate: + format_sql: true + use_sql_comments: true + jdbc: + time_zone: UTC + jackson: + time_zone: UTC + + discord: + webhook: + url: ${{ secrets.DEV_DISCORD_WEBHOOK_URL }} + + logging: + level: + root : INFO + org.springframework : INFO + org.hibernate.SQL: OFF + org.hibernate.jdbc.bind : OFF + # org.hibernate.type.descriptor.sql: trace + # org.springframework.web.socket: DEBUG + # org.springframework.messaging: DEBUG + # org.springframework.web.socket.messaging: DEBUG + # Flyway 설정 (MySQL 환경에서 활성화) + + flyway: + enabled: true + baseline-on-migrate: true + # baseline-version 제거: Flyway가 자동으로 현재 상태를 baseline으로 설정 + validate-on-migrate: true # 스키마 검증 활성화 + locations: classpath:db/migration + table: flyway_schema_history + clean-disabled: true # 프로덕션에서는 반드시 true + + springdoc: + override-with-generic-response: false + EOF + + - name: Generate application.yml for test + run: | + mkdir -p src/test/resources + cat < src/test/resources/application.yml + server: + port: ${SERVER_PORT:8080} + + security: + jwt: + token: + expire-length: 3600000 + secret-key: dummy-secret-key-should-be-long-enough-123456 + admin: + password: dummy-admin-password + + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: dummy-code-l-bucket + credentials: + access-key: dummy-access-key + secret-key: dummy-secret-key + + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + + spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + show-sql: true + database-platform: org.hibernate.dialect.H2Dialect + + springdoc: + override-with-generic-response: false + + logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql.BasicBinder: trace + + - name: Restore firebase-adminsdk.json + run: | + echo "$FIREBASE_CONFIG_JSON" > ./src/main/resources/code-l-b109b-firebase-adminsdk-fbsvc-8c4eb2e6f2.json + env: + FIREBASE_CONFIG_JSON: ${{ secrets.FIREBASE_CONFIG_JSON }} + + - name: Build jar + run: ./gradlew clean build -x test + + - name: Build Docker image + run: sudo docker build -t ${{ secrets.DOCKER_USERNAME }}/codel-app:dev . + + - name: Log in to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Push image to Docker Hub + run: sudo docker push ${{ secrets.DOCKER_USERNAME }}/codel-app:dev + + - name: Deploy to Dev Server + run: | + echo "📦 이미지 정보: ${{ secrets.DOCKER_USERNAME }}/codel-app:dev" + + echo "🛑 기존 컨테이너 중지 및 삭제" + sudo docker stop codel-app || true + sudo docker rm codel-app || true + + echo "🔄 최신 이미지 pull" + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/codel-app:dev + + echo "🚀 개발서버 배포 시작" + sudo docker run -d \ + --name codel-app \ + -p 8080:8080 \ + --restart unless-stopped \ + ${{ secrets.DOCKER_USERNAME }}/codel-app:dev + + echo "✅ 개발서버 배포 완료" + + echo "🔍 컨테이너 상태 확인" + sudo docker ps | grep codel-app diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 00000000..fcbdcf18 --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,199 @@ +name: CD - Deploy to Production Server + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, prod-runner] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant permission to gradlew + run: chmod +x ./gradlew + + - name: Generate application.yml + run: | + mkdir -p src/main/resources + cat < src/main/resources/application.yml + server: + port: ${SERVER_PORT:8080} + security: + jwt: + token: + expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }} + secret-key: ${{ secrets.JWT_SECRET_KEY }} + admin: + password: ${{ secrets.ADMIN_PASSWORD }} + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: code-l-bucket + credentials: + access-key: ${{ secrets.DEV_S3_ACCESS_KEY }} + secret-key: ${{ secrets.DEV_S3_SECRET_KEY }} + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + processor : false + spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${{ secrets.PROD_RDS_URL }} + username: ${{ secrets.DEV_RDS_USER_NAME }} + password: ${{ secrets.DEV_RDS_USER_PASSWORD }} + hikari : + maximum-pool-size : 20 + minimum-idle : 10 + connection-timeout: 10000 + max-lifetime: 180000 + + servlet: + multipart: + max-file-size: 20MB + max-request-size: 40MB + + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + show-sql: false + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate: + format_sql: true + use_sql_comments: true + jdbc: + time_zone: UTC + jackson: + time_zone: UTC + + discord: + webhook: + url: ${{ secrets.PROD_DISCORD_WEBHOOK_URL }} + + logging: + level: + root : INFO + org.springframework : INFO + org.hibernate.SQL: OFF + org.hibernate.jdbc.bind : OFF + # org.hibernate.type.descriptor.sql: trace + # org.springframework.web.socket: DEBUG + # org.springframework.messaging: DEBUG + # org.springframework.web.socket.messaging: DEBUG + # Flyway 설정 (MySQL 환경에서 활성화) + + flyway: + enabled: true + baseline-on-migrate: true + # baseline-version 제거: Flyway가 자동으로 현재 상태를 baseline으로 설정 + validate-on-migrate: true # 스키마 검증 활성화 + locations: classpath:db/migration + table: flyway_schema_history + clean-disabled: true # 프로덕션에서는 반드시 true + + springdoc: + override-with-generic-response: false + EOF + + - name: Generate application.yml for test + run: | + mkdir -p src/test/resources + cat < src/test/resources/application.yml + server: + port: ${SERVER_PORT:8080} + + security: + jwt: + token: + expire-length: 3600000 + secret-key: dummy-secret-key-should-be-long-enough-123456 + admin: + password: dummy-admin-password + + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: dummy-code-l-bucket + credentials: + access-key: dummy-access-key + secret-key: dummy-secret-key + + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + + spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + show-sql: true + database-platform: org.hibernate.dialect.H2Dialect + + springdoc: + override-with-generic-response: false + + logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql.BasicBinder: trace + + - name: Restore firebase-adminsdk.json + run: | + echo "$FIREBASE_CONFIG_JSON" > ./src/main/resources/code-l-b109b-firebase-adminsdk-fbsvc-8c4eb2e6f2.json + env: + FIREBASE_CONFIG_JSON: ${{ secrets.FIREBASE_CONFIG_JSON }} + + - name: Build jar + run: ./gradlew clean build -x test + + - name: Build Docker image + run: sudo docker build -t ${{ secrets.DOCKER_USERNAME }}/codel-app:prod . + + - name: Log in to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | sudo docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + + - name: Push image to Docker Hub + run: sudo docker push ${{ secrets.DOCKER_USERNAME }}/codel-app:prod + + - name: Zero-downtime Blue-Green Deploy + run: | + echo "📦 이미지 정보: ${{ secrets.DOCKER_USERNAME }}/codel-app:prod" + + echo "🔑 배포 스크립트 실행 권한 부여" + chmod +x ./scripts/blue-green-deploy.sh + + echo "🚀 운영서버 무중단 배포 시작" + ./scripts/blue-green-deploy.sh ${{ secrets.DOCKER_USERNAME }}/codel-app:prod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cd9c87c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI - PR to develop + +on: + pull_request: + branches: [ develop ] + +jobs: + build-and-test: + runs-on: [self-hosted, dev-runner] + + steps: + - name: Checkout source code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Generate application-dev.yml + run: | + mkdir -p src/main/resources + cat < src/main/resources/application.yml + server: + port: 8080 + security: + jwt: + token: + expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }} + secret-key: ${{ secrets.JWT_SECRET_KEY }} + admin: + password: ${{ secrets.ADMIN_PASSWORD }} + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: code-l-bucket + credentials: + access-key: ${{ secrets.DEV_S3_ACCESS_KEY }} + secret-key: ${{ secrets.DEV_S3_SECRET_KEY }} + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + processor: false + spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://codel-db.cbu0ugiswpor.ap-northeast-2.rds.amazonaws.com:3306/codel + username: ${{ secrets.DEV_RDS_USER_NAME }} + password: ${{ secrets.DEV_RDS_USER_PASSWORD }} + + jpa: + open-in-view: false + hibernate: + ddl-auto: create + show-sql: true + database-platform: org.hibernate.dialect.MySQL8Dialect + + discord: + webhook: + url: ${{ secrets.DEV_DISCORD_WEBHOOK_URL }} + + springdoc: + override-with-generic-response: false + EOF + + - name: Generate application.yml for test + run: | + mkdir -p src/test/resources + cat < src/test/resources/application.yml + server: + port: 8080 + + security: + jwt: + token: + expire-length: 3600000 + secret-key: dummy-secret-key-should-be-long-enough-123456 + admin: + password: dummy-admin-password + + cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: dummy-code-l-bucket + credentials: + access-key: dummy-access-key + secret-key: dummy-secret-key + + management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + + spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + show-sql: true + database-platform: org.hibernate.dialect.H2Dialect + + flyway: + enabled: false + + h2: + console: + enabled: true + + springdoc: + override-with-generic-response: false + + logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql.BasicBinder: trace + EOF + + - name: Restore firebase-adminsdk.json + run: | + echo "$FIREBASE_CONFIG_JSON" > ./src/main/resources/code-l-b109b-firebase-adminsdk-fbsvc-8c4eb2e6f2.json + env: + FIREBASE_CONFIG_JSON: ${{ secrets.FIREBASE_CONFIG_JSON }} + + - name: Build with Gradle + run: ./gradlew build + + - name: Run tests + run: ./gradlew test diff --git a/.gitignore b/.gitignore index b09902e1..e8d6e4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,10 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ -gradle/ +src/main/resources/*.yml +src/main/resources/*.json +src/main/resources/data.sql +/opt/ ### STS ### .apt_generated @@ -35,3 +38,5 @@ out/ ### VS Code ### .vscode/ +.history/ +docs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e6faa0d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:17-jre-jammy +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index c20ab546..196caaf5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,47 +1,83 @@ plugins { - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.25" - id("org.springframework.boot") version "3.4.3" - id("io.spring.dependency-management") version "1.1.7" - kotlin("plugin.jpa") version "1.9.25" + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.4.3" + id("io.spring.dependency-management") version "1.1.7" + kotlin("plugin.jpa") version "1.9.25" } group = "codel" version = "0.0.1-SNAPSHOT" java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("org.jetbrains.kotlin:kotlin-reflect") - runtimeOnly("com.h2database:h2") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // db + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-mysql") + runtimeOnly("com.mysql:mysql-connector-j") + runtimeOnly("com.h2database:h2") + + // test + testImplementation("io.rest-assured:rest-assured:5.3.1") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // jwt + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + + // swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4") + + // s3 + implementation("software.amazon.awssdk:s3:2.20.148") + + // monitoring + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") + + // fcm + implementation("com.google.firebase:firebase-admin:9.4.3") + + // logging + implementation("io.github.oshai:kotlin-logging-jvm:5.1.1") + implementation("com.github.loki4j:loki-logback-appender:1.4.0") + + // web + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + // web socket + implementation("org.springframework.boot:spring-boot-starter-websocket") } kotlin { - compilerOptions { - freeCompilerArgs.addAll("-Xjsr305=strict") - } + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } } allOpen { - annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.MappedSuperclass") - annotation("jakarta.persistence.Embeddable") + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") } tasks.withType { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e18bc253 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/scripts/blue-green-deploy.sh b/scripts/blue-green-deploy.sh new file mode 100644 index 00000000..b83093d8 --- /dev/null +++ b/scripts/blue-green-deploy.sh @@ -0,0 +1,193 @@ +#!/bin/bash +set -e + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🔄 무중단 블루-그린 배포 시작${NC}" + +# Docker 이미지 이름 (GitHub Actions에서 전달받을 예정) +IMAGE_NAME=${1:-"docker_username/codel-app:latest"} + +# Health check 함수 +check_health() { + local port=$1 + # actuator health 먼저 시도, 실패하면 간단한 health 시도 + curl -f -s http://localhost:$port/actuator/health > /dev/null 2>&1 || \ + curl -f -s http://localhost:$port/health > /dev/null 2>&1 +} + +# 현재 활성 포트 감지 +echo -e "${YELLOW}📍 현재 활성 포트 감지 중...${NC}" +if check_health 8080; then + CURRENT_PORT=8080 + NEW_PORT=8081 +elif check_health 8081; then + CURRENT_PORT=8081 + NEW_PORT=8080 +else + echo -e "${YELLOW}⚠️ 기존 서비스가 없습니다. 8080 포트로 시작합니다.${NC}" + CURRENT_PORT=0 + NEW_PORT=8080 +fi + +echo -e "${GREEN}✅ 현재 포트: $CURRENT_PORT, 새 포트: $NEW_PORT${NC}" + +# 1. 새 컨테이너 시작 +echo -e "${BLUE}🚀 새 컨테이너 시작 (포트: $NEW_PORT)${NC}" + +# 기존 컨테이너가 있다면 정리 +sudo docker stop codel-$NEW_PORT 2>/dev/null || true +sudo docker rm codel-$NEW_PORT 2>/dev/null || true + +# 새 컨테이너 시작 +sudo docker run -d \ + --name codel-$NEW_PORT \ + -p $NEW_PORT:8080 \ + -v /var/log/app:/var/log/app \ + -e TZ=UTC \ + -e SERVER_PORT=8080 \ + -e JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:-UseContainerSupport" \ + $IMAGE_NAME + +# 2. Health Check 대기 +echo -e "${YELLOW}⏳ 새 컨테이너 Health Check 대기 중...${NC}" +for i in {1..60}; do + if check_health $NEW_PORT; then + echo -e "${GREEN}✅ 새 컨테이너 준비 완료 ($i초 소요)${NC}" + break + fi + if [ $i -eq 60 ]; then + echo -e "${RED}❌ Health Check 실패: 새 컨테이너가 준비되지 않았습니다${NC}" + sudo docker logs codel-$NEW_PORT + exit 1 + fi + echo -n "." + sleep 2 +done + +# 3. Nginx 설정 전환 +echo -e "${BLUE}🔄 Nginx 설정 전환 중...${NC}" + +# 백업 생성 +sudo cp /etc/nginx/conf.d/www.codelg.store.conf /etc/nginx/conf.d/www.codelg.store.conf.backup + +# 포트 변경 (Spring Boot upstream만 변경, Next.js는 그대로 유지) +# upstream backend 블록 내의 8080/8081 포트만 변경 +if [ $CURRENT_PORT -ne 0 ]; then + # 기존 포트가 있는 경우: CURRENT_PORT -> NEW_PORT + sudo sed -i "/upstream backend/,/}/s/server localhost:$CURRENT_PORT/server localhost:$NEW_PORT/g" /etc/nginx/conf.d/www.codelg.store.conf +else + # 기존 포트가 없는 경우: 8080 또는 8081 중 하나를 NEW_PORT로 + sudo sed -i "/upstream backend/,/}/s/server localhost:808[01]/server localhost:$NEW_PORT/g" /etc/nginx/conf.d/www.codelg.store.conf +fi + +# 설정 검증 +sudo nginx -t +if [ $? -ne 0 ]; then + echo -e "${RED}❌ Nginx 설정 오류: 롤백합니다${NC}" + sudo cp /etc/nginx/conf.d/www.codelg.store.conf.backup /etc/nginx/conf.d/www.codelg.store.conf + exit 1 +fi + +# Nginx 리로드 +sudo nginx -s reload +echo -e "${GREEN}✅ Nginx 설정 전환 완료${NC}" + +# 4. 외부 접근 테스트 +# 4. 외부 접근 테스트 (상세 버전) +echo -e "${YELLOW}🌐 외부 접근 테스트 중...${NC}" + +# 단계별 테스트 +echo -e "${BLUE}1️⃣ 로컬 포트 직접 확인${NC}" +LOCAL_TEST=$(curl -f -s -m 5 http://localhost:$NEW_PORT/actuator/health 2>/dev/null || echo "FAILED") +if [ "$LOCAL_TEST" = "FAILED" ]; then + echo -e "${RED}❌ 로컬 포트 $NEW_PORT 응답 없음${NC}" + echo "컨테이너 상태:" + sudo docker ps | grep codel-$NEW_PORT + echo "컨테이너 로그:" + sudo docker logs --tail 10 codel-$NEW_PORT + exit 1 +else + echo -e "${GREEN}✅ 로컬 포트 $NEW_PORT 응답 정상${NC}" +fi + +echo -e "${BLUE}2️⃣ Nginx upstream 설정 확인${NC}" +UPSTREAM_PORT=$(sudo grep -A 3 "upstream backend" /etc/nginx/conf.d/www.codelg.store.conf | grep "server localhost" | grep -o "[0-9]\+") +echo -e "${BLUE} 현재 upstream 포트: $UPSTREAM_PORT${NC}" +if [ "$UPSTREAM_PORT" != "$NEW_PORT" ]; then + echo -e "${RED}❌ Upstream 포트가 새 포트와 다릅니다 ($UPSTREAM_PORT ≠ $NEW_PORT)${NC}" + exit 1 +fi + +echo -e "${BLUE}3️⃣ Nginx 프로세스 상태 확인${NC}" +if ! sudo systemctl is-active --quiet nginx; then + echo -e "${RED}❌ Nginx 서비스가 실행되지 않고 있습니다${NC}" + sudo systemctl status nginx --no-pager + exit 1 +else + echo -e "${GREEN}✅ Nginx 서비스 정상${NC}" +fi + +echo -e "${BLUE}4️⃣ 외부 HTTPS 접근 테스트 (타임아웃 10초)${NC}" +sleep 3 + +# 더 짧은 타임아웃으로 빠른 실패 +EXTERNAL_TEST=$(curl -f -s -m 10 --connect-timeout 5 https://codelg.store/actuator/health 2>&1) +CURL_EXIT_CODE=$? + +if [ $CURL_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✅ 외부 접근 정상${NC}" + echo "응답: $EXTERNAL_TEST" +else + echo -e "${RED}❌ 외부 접근 실패 (종료코드: $CURL_EXIT_CODE)${NC}" + + # curl 에러 코드별 메시지 + case $CURL_EXIT_CODE in + 6) echo "DNS 해석 실패" ;; + 7) echo "서버 연결 실패" ;; + 28) echo "타임아웃" ;; + 22) echo "HTTP 에러 응답" ;; + *) echo "기타 curl 오류" ;; + esac + + echo "상세 응답: $EXTERNAL_TEST" + + echo -e "${RED}=== 디버깅 정보 ===${NC}" + echo "Nginx 에러 로그 (최근 5줄):" + sudo tail -5 /var/log/nginx/error.log + + echo "Nginx 액세스 로그 (최근 3줄):" + sudo tail -3 /var/log/nginx/access.log + + echo "포트 상태:" + sudo netstat -tlnp | grep -E ":(443|80|808[01])" + + # 롤백 + echo -e "${RED}롤백을 진행합니다${NC}" + sudo cp /etc/nginx/conf.d/www.codelg.store.conf.backup /etc/nginx/conf.d/www.codelg.store.conf + sudo nginx -s reload + exit 1 +fi + +# 5. 기존 컨테이너 정리 (포트 0이면 건너뛰기) +if [ $CURRENT_PORT -ne 0 ]; then + echo -e "${YELLOW}🧹 기존 컨테이너 정리 중...${NC}" + sleep 2 # 안전 여유시간 + sudo docker stop codel-$CURRENT_PORT || true + sudo docker rm codel-$CURRENT_PORT || true + echo -e "${GREEN}✅ 기존 컨테이너 정리 완료${NC}" +fi + +# 6. 정리 작업 +echo -e "${YELLOW}🧹 정리 작업 중...${NC}" +# 사용하지 않는 이미지 정리 +sudo docker image prune -f + +echo -e "${GREEN}🎉 무중단 배포 완료!${NC}" +echo -e "${GREEN} 활성 포트: $NEW_PORT${NC}" +echo -e "${GREEN} 서비스 URL: https://codelg.store${NC}" diff --git a/src/main/kotlin/codel/CodelApplication.kt b/src/main/kotlin/codel/CodelApplication.kt index 289465ff..b88a635a 100644 --- a/src/main/kotlin/codel/CodelApplication.kt +++ b/src/main/kotlin/codel/CodelApplication.kt @@ -1,11 +1,13 @@ package codel import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication @SpringBootApplication +@ConfigurationPropertiesScan class CodelApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/codel/admin/business/AdminService.kt b/src/main/kotlin/codel/admin/business/AdminService.kt new file mode 100644 index 00000000..395bf7c1 --- /dev/null +++ b/src/main/kotlin/codel/admin/business/AdminService.kt @@ -0,0 +1,536 @@ +package codel.admin.business + +import codel.admin.domain.Admin +import codel.admin.presentation.request.ImageRejection +import codel.auth.business.AuthService +import codel.config.Loggable +import codel.member.business.MemberService +import codel.member.domain.ImageUploader +import codel.member.domain.Member +import codel.member.domain.RejectionHistory +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import codel.question.business.QuestionService +import codel.question.domain.Question +import codel.question.domain.QuestionCategory +import codel.verification.domain.StandardVerificationImage +import codel.verification.domain.VerificationImage +import codel.verification.infrastructure.StandardVerificationImageJpaRepository +import codel.verification.infrastructure.VerificationImageJpaRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException + +@Service +@Transactional(readOnly = true) +class AdminService( + private val memberService: MemberService, + private val authService: AuthService, + private val asyncNotificationService: IAsyncNotificationService, + private val questionService: QuestionService, + private val standardVerificationImageRepository: StandardVerificationImageJpaRepository, + private val verificationImageRepository: VerificationImageJpaRepository, + private val imageUploader: ImageUploader, + @Value("\${security.admin.password}") + private val answerPassword: String, +) : Loggable{ + @Transactional + fun loginAdmin(admin: Admin): String { + admin.validatePassword(answerPassword) + + val member = + Member( + oauthType = admin.oauthType, + oauthId = admin.oauthId, + memberStatus = admin.memberStatus, + email = "hogee", + ) + + memberService.loginMember(member) + + return authService.provideToken(member) + } + + fun findPendingMembers(): List = memberService.findPendingMembers() + + fun findMember(memberId: Long): Member = memberService.findMember(memberId) + + /** + * 관리자용: 이미지 포함해서 회원 조회 + */ + fun findMemberWithImages(memberId: Long): Member = memberService.findMemberWithImages(memberId) + + @Transactional + fun approveMemberProfile(memberId: Long) { + val approvedMember = memberService.approveMember(memberId) + sendApprovalNotification(approvedMember) + } + + @Transactional + fun rejectMemberProfile( + memberId: Long, + reason: String, + ) { + val rejectedMember = memberService.rejectMember(memberId, reason) + sendRejectionNotification(rejectedMember) + } + + /** + * 이미지별 거절 처리 (신규) + */ + @Transactional + fun rejectMemberProfileWithImages( + memberId: Long, + faceImageRejections: List?, + codeImageRejections: List? + ) { + val rejectedMember = memberService.rejectMemberWithImages(memberId, faceImageRejections, codeImageRejections) + sendRejectionNotification(rejectedMember) + } + + // ========== 알림 전송 메서드 ========== + + /** + * 승인 알림 전송 (FCM + Discord) + */ + private fun sendApprovalNotification(member: Member) { + // 1. FCM 알림 전송 + sendApprovalFcmNotification(member) + + // 2. Discord 알림 전송 + sendApprovalDiscordNotification(member) + } + + /** + * 반려 알림 전송 (FCM + Discord) + */ + private fun sendRejectionNotification(member: Member) { + // 1. FCM 알림 전송 + sendRejectionFcmNotification(member) + + // 2. Discord 알림 전송 + sendRejectionDiscordNotification(member) + } + + /** + * 승인 FCM 알림 + */ + private fun sendApprovalFcmNotification(member: Member) { + member.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "프로필 심사가 완료되었어요 ✅", + body = "이제 Code:L을 이용할 수 있어요. 코드가 맞는 우리만의 공간에서 진짜 인연을 만나보세요." + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 프로필 승인 알림 전송 성공 - 회원: ${member.getIdOrThrow()}" } + } else { + log.warn { "❌ 프로필 승인 알림 전송 실패 - 회원: ${member.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 프로필 승인 알림 전송 예외 발생 - 회원: ${member.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 프로필 승인 알림을 전송하지 않음 - 회원: ${member.getIdOrThrow()}" } + } + } + + /** + * 반려 FCM 알림 + */ + private fun sendRejectionFcmNotification(member: Member) { + member.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "프로필 심사가 반려되었습니다 ❌", + body = "자세한 이유는 앱에서 확인할 수 있습니다." + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 프로필 반려 알림 전송 성공 - 회원: ${member.getIdOrThrow()}" } + } else { + log.warn { "❌ 프로필 반려 알림 전송 실패 - 회원: ${member.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 프로필 반려 알림 전송 예외 발생 - 회원: ${member.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 프로필 반려 알림을 전송하지 않음 - 회원: ${member.getIdOrThrow()}" } + } + } + + /** + * 승인 Discord 알림 + */ + private fun sendApprovalDiscordNotification(member: Member) { + try { + val notification = Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "✅ 프로필 승인 완료", + body = """ + **회원 프로필이 승인되었습니다.** + + 👤 **회원 정보** + • 코드네임: **${member.getProfileOrThrow().getCodeNameOrThrow()}** + • 회원 ID: ${member.getIdOrThrow()} + + 📱 **알림 전송** + • FCM 알림: ${if (member.fcmToken != null) "전송 완료 ✅" else "토큰 없음 ⚠️"} + + 🕐 **처리 시각** + • ${java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))} (KST) + """.trimIndent() + ) + + // Discord는 동기 전송 유지 (관리자용이므로) + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ Discord 승인 알림 전송 완료 - 회원: ${member.getIdOrThrow()}" } + } else { + log.warn { "❌ Discord 승인 알림 전송 실패 - 회원: ${member.getIdOrThrow()}" } + } + } + } catch (e: Exception) { + log.warn(e) { "❌ Discord 승인 알림 전송 실패 - 회원: ${member.getIdOrThrow()}" } + } + } + + /** + * 반려 Discord 알림 + */ + private fun sendRejectionDiscordNotification(member: Member) { + try { + val rejectReason = member.rejectReason ?: "사유 없음" + + val notification = Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "❌ 프로필 반려 처리", + body = """ + **회원 프로필이 반려되었습니다.** + + 👤 **회원 정보** + • 코드네임: **${member.getProfileOrThrow().getCodeNameOrThrow()}** + • 회원 ID: ${member.getIdOrThrow()} + + 📝 **반려 사유** + • $rejectReason + + 📱 **알림 전송** + • FCM 알림: ${if (member.fcmToken != null) "전송 완료 ✅" else "토큰 없음 ⚠️"} + + 🕐 **처리 시각** + • ${java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))} (KST) + """.trimIndent() + ) + + // Discord는 동기 전송 유지 (관리자용이므로) + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ Discord 반려 알림 전송 완료 - 회원: ${member.getIdOrThrow()}" } + } else { + log.warn { "❌ Discord 반려 알림 전송 실패 - 회원: ${member.getIdOrThrow()}" } + } + } + } catch (e: Exception) { + log.warn(e) { "❌ Discord 반려 알림 전송 실패 - 회원: ${member.getIdOrThrow()}" } + } + } + + + fun countAllMembers(): Long = memberService.countAllMembers() + + fun countPendingMembers(): Long = memberService.countPendingMembers() + + fun findMembersWithFilter( + keyword: String?, + status: String?, + pageable: Pageable, + ): Page = memberService.findMembersWithFilter(keyword, status, pageable) + + fun findMembersWithFilter( + keyword: String?, + status: String?, + startDate: String?, + endDate: String?, + sort: String?, + direction: String?, + pageable: Pageable, + ): Page = memberService.findMembersWithFilter(keyword, status, startDate, endDate, sort, direction, pageable) + + fun countMembersByStatus(status: String): Long = memberService.countMembersByStatus(status) + + // ========== 질문 관리 ========== + + fun findQuestionsWithFilter( + keyword: String?, + category: String?, + isActive: Boolean?, + pageable: Pageable + ): Page = questionService.findQuestionsWithFilter(keyword, category, isActive, pageable) + + fun findQuestionById(questionId: Long): Question = questionService.findQuestionById(questionId) + + @Transactional + fun createQuestion( + content: String, + category: QuestionCategory, + description: String?, + isActive: Boolean + ): Question = questionService.createQuestion(content, category, description, isActive) + + @Transactional + fun updateQuestion( + questionId: Long, + content: String, + category: QuestionCategory, + description: String?, + isActive: Boolean + ): Question = questionService.updateQuestion(questionId, content, category, description, isActive) + + @Transactional + fun deleteQuestion(questionId: Long) = questionService.deleteQuestion(questionId) + + @Transactional + fun toggleQuestionStatus(questionId: Long): Question = questionService.toggleQuestionStatus(questionId) + + // ========== 통계 관련 메서드 ========== + + fun getDailySignupStats(): List> = memberService.getDailySignupStats() + + fun getMemberStatusStats(): Map = memberService.getMemberStatusStats() + + fun getMonthlySignupStats(): List> = memberService.getMonthlySignupStats() + + fun getTodaySignupCount(): Long = memberService.getTodaySignupCount() + + fun getWeeklySignupCount(): Long = memberService.getWeeklySignupCount() + + fun getMonthlySignupCount(): Long = memberService.getMonthlySignupCount() + + fun getApprovalRate(): Double { + val statusStats = getMemberStatusStats() + val doneCount = statusStats["DONE"] ?: 0L + val pendingCount = statusStats["PENDING"] ?: 0L + val rejectCount = statusStats["REJECT"] ?: 0L + + val totalProcessed = doneCount + rejectCount + return if (totalProcessed > 0) { + (doneCount.toDouble() / totalProcessed.toDouble()) * 100 + } else { + 0.0 + } + } + + // ========== 프로필 관련 추가 메서드들 ========== + + // 회원 활동 히스토리 조회 (임시 구현) + fun getMemberActivityHistory(memberId: Long): List { + // 실제 구현에서는 활동 기록을 저장하는 테이블에서 조회 + return emptyList() + } + + // 회원 상태 변경 히스토리 조회 (임시 구현) + fun getMemberStatusHistory(memberId: Long): List { + // 실제 구현에서는 상태 변경 이력을 저장하는 테이블에서 조회 + return emptyList() + } + + // 회원 로그인 히스토리 조회 (임시 구현) + fun getMemberLoginHistory(memberId: Long, limit: Int): List { + // 실제 구현에서는 로그인 기록을 저장하는 테이블에서 조회 + return emptyList() + } + + // 회원 신고 히스토리 조회 (임시 구현) + fun getMemberReportHistory(memberId: Long): List { + // 실제 구현에서는 신고 기록을 저장하는 테이블에서 조회 + return emptyList() + } + + // 관리자 메모 조회 (임시 구현) + fun getAdminNotes(memberId: Long): List { + // 실제 구현에서는 관리자 메모를 저장하는 테이블에서 조회 + return emptyList() + } + + // 최근 회원 활동 조회 (임시 구현) + fun getRecentMemberActivity(memberId: Long, limit: Int): List { + // 실제 구현에서는 최근 활동을 조회 + return emptyList() + } + + // 회원 통계 조회 (임시 구현) + fun getMemberStatistics(memberId: Long): MemberStatistics? { + // 실제 구현에서는 회원별 통계를 계산 + return null + } + + // ========== 데이터 클래스들 ========== + + data class MemberActivity( + val id: Long?, + val memberId: Long, + val type: String, + val description: String, + val createdAt: java.time.LocalDateTime + ) + + data class MemberStatusHistory( + val id: Long?, + val memberId: Long, + val fromStatus: String, + val toStatus: String, + val reason: String?, + val createdAt: java.time.LocalDateTime + ) + + data class MemberLoginHistory( + val id: Long?, + val memberId: Long, + val loginAt: java.time.LocalDateTime, + val ipAddress: String?, + val userAgent: String? + ) + + data class MemberReport( + val id: Long?, + val memberId: Long, + val reporterName: String, + val reason: String, + val createdAt: java.time.LocalDateTime + ) + + data class AdminNote( + val id: Long?, + val memberId: Long, + val adminName: String, + val content: String, + val createdAt: java.time.LocalDateTime + ) + + data class MemberStatistics( + val loginCount: Int, + val activityScore: Double, + val reportCount: Int, + val daysSinceJoin: Int, + val profileCompletionRate: Double, + val lastActiveDate: java.time.LocalDate?, + val totalImages: Int, + val hasIntroduce: Boolean + ) + + // ========== 거절 이력 관리 ========== + + /** + * 특정 회원의 모든 거절 이력 조회 + */ + fun getRejectionHistories(memberId: Long): List { + return memberService.getRejectionHistories(memberId) + } + + /** + * 특정 회원의 특정 차수 거절 이력 조회 + */ + fun getRejectionHistoriesByRound(memberId: Long, rejectionRound: Int): List { + return memberService.getRejectionHistoriesByRound(memberId, rejectionRound) + } + + /** + * 특정 회원의 최대 거절 차수 조회 + */ + fun getMaxRejectionRound(memberId: Long): Int { + return memberService.getMaxRejectionRound(memberId) + } + + // ===== 표준 인증 이미지 관리 ===== + + /** + * 모든 표준 인증 이미지 조회 (관리자용) + */ + fun getAllStandardVerificationImages(): List { + return standardVerificationImageRepository.findAll().sortedByDescending { it.createdAt } + } + + /** + * 활성화된 표준 인증 이미지 조회 + */ + fun getActiveStandardVerificationImages(): List { + return standardVerificationImageRepository.findAllByIsActiveTrue().sortedByDescending { it.createdAt } + } + + /** + * 표준 인증 이미지 생성 + */ + @Transactional + fun createStandardVerificationImage( + imageFile: MultipartFile, + description: String? + ): StandardVerificationImage { + // S3에 이미지 업로드 + val imageUrl = imageUploader.uploadFile(imageFile) + + // 표준 인증 이미지 엔티티 생성 + val standardImage = StandardVerificationImage( + imageUrl = imageUrl, + description = description ?: "", + isActive = true + ) + + return standardVerificationImageRepository.save(standardImage) + } + + /** + * 표준 인증 이미지 활성화/비활성화 토글 + */ + @Transactional + fun toggleStandardImageStatus(imageId: Long): StandardVerificationImage { + val image = standardVerificationImageRepository.findById(imageId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "표준 인증 이미지를 찾을 수 없습니다. ID: $imageId") + } + image.isActive = !image.isActive + return image + } + + /** + * 표준 인증 이미지 삭제 + */ + @Transactional + fun deleteStandardVerificationImage(imageId: Long) { + val image = standardVerificationImageRepository.findById(imageId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "표준 인증 이미지를 찾을 수 없습니다. ID: $imageId") + } + standardVerificationImageRepository.delete(image) + // S3에서 이미지 삭제는 별도 처리 필요 (비동기 권장) + } + + /** + * 회원의 최신 인증 이미지 조회 (있는 경우만) + * standardVerificationImage를 함께 fetch + */ + fun getMemberVerificationImage(member: Member): VerificationImage? { + return verificationImageRepository.findFirstByMemberWithStandardImage(member) + } +} diff --git a/src/main/kotlin/codel/admin/domain/Admin.kt b/src/main/kotlin/codel/admin/domain/Admin.kt new file mode 100644 index 00000000..d97674b3 --- /dev/null +++ b/src/main/kotlin/codel/admin/domain/Admin.kt @@ -0,0 +1,19 @@ +package codel.admin.domain + +import codel.admin.exception.AdminException +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import org.springframework.http.HttpStatus + +class Admin( + val password: String, + val oauthType: OauthType = OauthType.ADMIN, + val oauthId: String = "admin", + val memberStatus: MemberStatus = MemberStatus.ADMIN, +) { + fun validatePassword(targetPassword: String) { + if (password != targetPassword) { + throw AdminException(HttpStatus.UNAUTHORIZED, "패스워드가 일치하지 않습니다.") + } + } +} diff --git a/src/main/kotlin/codel/admin/exception/AdminException.kt b/src/main/kotlin/codel/admin/exception/AdminException.kt new file mode 100644 index 00000000..3badaad2 --- /dev/null +++ b/src/main/kotlin/codel/admin/exception/AdminException.kt @@ -0,0 +1,9 @@ +package codel.admin.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class AdminException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/admin/presentation/AdminController.kt b/src/main/kotlin/codel/admin/presentation/AdminController.kt new file mode 100644 index 00000000..2dd171f0 --- /dev/null +++ b/src/main/kotlin/codel/admin/presentation/AdminController.kt @@ -0,0 +1,692 @@ +package codel.admin.presentation + +import codel.admin.business.AdminService +import codel.report.business.ReportAdminService +import codel.report.domain.ReportStatus +import codel.admin.domain.Admin +import codel.admin.exception.AdminException +import codel.admin.presentation.request.AdminLoginRequest +import codel.admin.presentation.request.RejectProfileRequest +import codel.member.domain.Member +import codel.question.domain.QuestionCategory +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.* +import org.springframework.web.servlet.mvc.support.RedirectAttributes + +@Controller +class AdminController( + private val adminService: AdminService, + private val reportAdminService: ReportAdminService, +) { + @GetMapping("/v1/admin/login") + fun login(): String = "login" + + @PostMapping("/v1/admin/login") + fun login( + @ModelAttribute adminLoginRequest: AdminLoginRequest, + response: HttpServletResponse, + model: Model, + ): String { + val admin = Admin(adminLoginRequest.password) + + return try { + val token = adminService.loginAdmin(admin) + addCookie(token, response) + "redirect:/v1/admin/home" + } catch (e: AdminException) { + model.addAttribute("error", e.message) + "login" + } + } + + private fun addCookie( + token: String, + response: HttpServletResponse, + ) { + val cookie = Cookie("access_token", token) + cookie.path = "/v1/admin" + cookie.maxAge = 86400 + + response.addCookie(cookie) + } + + @GetMapping("/v1/admin/home") + fun home(model: Model): String { + // 기본 통계 + val totalMembers = adminService.countAllMembers() + val pendingMembers = adminService.countPendingMembers() + val todaySignups = adminService.getTodaySignupCount() + val weeklySignups = adminService.getWeeklySignupCount() + val monthlySignups = adminService.getMonthlySignupCount() + val approvalRate = adminService.getApprovalRate() + + // 상태별 통계 + val statusStats = adminService.getMemberStatusStats() + + // 일별 가입자 통계 (차트용) + val dailyStats = adminService.getDailySignupStats() + + // 월별 가입자 통계 + val monthlyStats = adminService.getMonthlySignupStats() + + model.addAttribute("totalMembers", totalMembers) + model.addAttribute("pendingMembers", pendingMembers) + model.addAttribute("todaySignups", todaySignups) + model.addAttribute("weeklySignups", weeklySignups) + model.addAttribute("monthlySignups", monthlySignups) + model.addAttribute("approvalRate", String.format("%.1f", approvalRate)) + model.addAttribute("statusStats", statusStats) + model.addAttribute("dailyStats", dailyStats) + model.addAttribute("monthlyStats", monthlyStats) + + return "home" + } + + @GetMapping("/v1/admin/member/{memberId}") + fun findMemberDetail( + model: Model, + @PathVariable memberId: Long, + ): String { + println("🔍 AdminController.findMemberDetail 호출됨 - memberId: $memberId") + + try { + // 기본 회원 정보 조회 (이미지 포함) + println("📄 회원 정보 조회 시작") + val member = adminService.findMemberWithImages(memberId) + println("✅ 회원 정보 조회 성공: ${member.email}") + + // 프로필 정보 안전하게 가져오기 + val profile = member.profile + println("📋 프로필 정보: ${if (profile != null) "존재함" else "없음"}") + + // 이미지 리스트 안전하게 가져오기 (ID와 URL 포함) + val codeImages = try { + profile?.codeImages?.sortedBy { it.orders }?.map { + mapOf("id" to it.id, "url" to it.url, "isApproved" to it.isApproved, "rejectionReason" to it.rejectionReason) + } ?: emptyList() + } catch (e: Exception) { + println("⚠️ Error getting code images: ${e.message}") + emptyList>() + } + + val faceImages = try { + profile?.faceImages?.sortedBy { it.orders }?.map { + mapOf("id" to it.id, "url" to it.url, "isApproved" to it.isApproved, "rejectionReason" to it.rejectionReason) + } ?: emptyList() + } catch (e: Exception) { + println("⚠️ Error getting face images: ${e.message}") + emptyList>() + } + + println("🖼️ 이미지 정보 - 코드: ${codeImages.size}개, 페이스: ${faceImages.size}개") + + // 추가 정보들 조회 (옵셔널) + val activityHistory = try { + // adminService.getMemberActivityHistory(memberId) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + val statusHistory = try { + // adminService.getMemberStatusHistory(memberId) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + val loginHistory = try { + // adminService.getMemberLoginHistory(memberId, 10) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + val reportHistory = try { + // adminService.getMemberReportHistory(memberId) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + val adminNotes = try { + // adminService.getAdminNotes(memberId) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + val recentActivity = try { + // adminService.getRecentMemberActivity(memberId, 5) + emptyList() // 명시적 타입 지정 + } catch (e: Exception) { + emptyList() + } + + // 회원 통계 정보 (옵셔널) + val memberStats = try { + // adminService.getMemberStatistics(memberId) + null // 임시로 null + } catch (e: Exception) { + null + } + + val repQuestionContent = try { + profile?.representativeQuestion?.content + } catch (e: Exception) { + println("⚠️ Error getting representative question: ${e.message}") + null + } + + // 인증 이미지 조회 (없을 수도 있음) + val verificationImage = try { + adminService.getMemberVerificationImage(member) + } catch (e: Exception) { + println("⚠️ Error getting verification image: ${e.message}") + null + } + + // 모델에 모든 데이터 추가 + model.addAttribute("member", member) + model.addAttribute("codeImages", codeImages) + model.addAttribute("faceImages", faceImages) + model.addAttribute("verificationImage", verificationImage) + model.addAttribute("activityHistory", activityHistory) + model.addAttribute("statusHistory", statusHistory) + model.addAttribute("loginHistory", loginHistory) + model.addAttribute("repQuestionContent", repQuestionContent) + model.addAttribute("reportHistory", reportHistory) + model.addAttribute("adminNotes", adminNotes) + model.addAttribute("recentActivity", recentActivity) + model.addAttribute("memberStats", memberStats) + + return "memberDetail" + + } catch (e: Exception) { + println("Error in findMemberDetail for memberId $memberId: ${e.message}") + e.printStackTrace() + + // 오류 발생 시 회원 목록으로 리다이렉트 + model.addAttribute("error", "회원 정보를 불러올 수 없습니다 (ID: $memberId)") + return "redirect:/v1/admin/members" + } + } + + /** + * 이미지 심사 전용 페이지 + */ + @GetMapping("/v1/admin/member/{memberId}/image-review") + fun memberImageReview( + model: Model, + @PathVariable memberId: Long + ): String { + try { + val member = adminService.findMemberWithImages(memberId) + val profile = member.profile + + // 이미지 리스트 가져오기 + val codeImages = profile?.codeImages?.sortedBy { it.orders }?.map { + mapOf("id" to it.id, "url" to it.url) + } ?: emptyList() + + val faceImages = profile?.faceImages?.sortedBy { it.orders }?.map { + mapOf("id" to it.id, "url" to it.url) + } ?: emptyList() + + // 인증 이미지 조회 (없을 수도 있음) + val verificationImage = try { + adminService.getMemberVerificationImage(member) + } catch (e: Exception) { + null + } + + // 표준 이미지 정보 (인증 이미지가 있는 경우) + val standardImage = verificationImage?.standardVerificationImage + + model.addAttribute("member", member) + model.addAttribute("codeImages", codeImages) + model.addAttribute("faceImages", faceImages) + model.addAttribute("verificationImage", verificationImage) + model.addAttribute("standardImage", standardImage) + + return "memberImageReview" + } catch (e: Exception) { + e.printStackTrace() + model.addAttribute("error", "이미지를 불러올 수 없습니다") + return "redirect:/v1/admin/member/$memberId" + } + } + + @PostMapping("/v1/admin/approval/{memberId}") + fun approveMember( + @PathVariable memberId: Long, + ): String { + adminService.approveMemberProfile(memberId) + + return "redirect:/v1/admin/home" + } + + @PostMapping("/v1/admin/reject/{memberId}") + fun rejectMember( + @PathVariable memberId: Long, + @RequestParam rejectReason: String, + ): String { + adminService.rejectMemberProfile(memberId, rejectReason) + + return "redirect:/v1/admin/home" + } + + /** + * 이미지별 거절 처리 API (신규) + */ + @PostMapping("/v1/admin/reject-images/{memberId}") + @ResponseBody + fun rejectMemberWithImages( + @PathVariable memberId: Long, + @RequestBody request: RejectProfileRequest + ): ResponseEntity> { + adminService.rejectMemberProfileWithImages( + memberId, + request.faceImageRejections, + request.codeImageRejections + ) + + return ResponseEntity.ok(mapOf("message" to "프로필 거절 처리가 완료되었습니다")) + } + + @GetMapping("/v1/admin/members") + fun memberList( + model: Model, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) status: String?, + @RequestParam(required = false) startDate: String?, + @RequestParam(required = false) endDate: String?, + @RequestParam(required = false, defaultValue = "createdAt") sort: String?, + @RequestParam(required = false, defaultValue = "desc") direction: String?, + @PageableDefault(size = 20) pageable: Pageable + ): String { + val members: Page = adminService.findMembersWithFilter(keyword, status, startDate, endDate, sort, direction, pageable) + + // 각 상태별 회원 수 조회 + val statusCounts = mapOf( + "total" to adminService.countAllMembers(), + "PENDING" to adminService.countMembersByStatus("PENDING"), + "DONE" to adminService.countMembersByStatus("DONE"), + "REJECT" to adminService.countMembersByStatus("REJECT"), + "PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED") + ) + + model.addAttribute("members", members) + model.addAttribute("statusCounts", statusCounts) + model.addAttribute("param", mapOf( + "keyword" to (keyword ?: ""), + "status" to (status ?: ""), + "startDate" to (startDate ?: ""), + "endDate" to (endDate ?: ""), + "sort" to (sort ?: "createdAt"), + "direction" to (direction ?: "desc") + )) + return "memberList" + } + + @PostMapping("/v1/admin/members/bulk-action") + fun bulkAction( + @RequestParam action: String, + @RequestParam memberIds: List, + @RequestParam(required = false) rejectReason: String? + ): String { + when (action) { + "approve" -> memberIds.forEach { adminService.approveMemberProfile(it) } + "reject" -> { + val reason = rejectReason ?: "일괄 거부" + memberIds.forEach { adminService.rejectMemberProfile(it, reason) } + } + } + return "redirect:/v1/admin/members" + } + + // ========== 질문 관리 ========== + + @GetMapping("/v1/admin/questions") + fun questionList( + model: Model, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) category: String?, + @RequestParam(required = false) isActive: Boolean?, + @PageableDefault(size = 20) pageable: Pageable + ): String { + val questions = adminService.findQuestionsWithFilter(keyword, category, isActive, pageable) + model.addAttribute("questions", questions) + model.addAttribute("categories", QuestionCategory.values()) + model.addAttribute("param", mapOf( + "keyword" to (keyword ?: ""), + "category" to (category ?: ""), + "isActive" to (isActive?.toString() ?: "") + )) + return "questionList" + } + + @GetMapping("/v1/admin/questions/new") + fun questionForm(model: Model): String { + model.addAttribute("categories", QuestionCategory.values()) + return "questionForm" + } + + @PostMapping("/v1/admin/questions") + fun createQuestion( + @RequestParam content: String, + @RequestParam category: String, + @RequestParam(required = false) description: String?, + @RequestParam(defaultValue = "true") isActive: Boolean, + redirectAttributes: RedirectAttributes + ): String { + try { + val questionCategory = QuestionCategory.valueOf(category) + adminService.createQuestion(content, questionCategory, description, isActive) + redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 등록되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "질문 등록에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/questions" + } + + @GetMapping("/v1/admin/questions/{questionId}/edit") + fun editQuestionForm( + @PathVariable questionId: Long, + model: Model + ): String { + val question = adminService.findQuestionById(questionId) + model.addAttribute("question", question) + model.addAttribute("categories", QuestionCategory.values()) + return "questionEditForm" + } + + @PostMapping("/v1/admin/questions/{questionId}") + fun updateQuestion( + @PathVariable questionId: Long, + @RequestParam content: String, + @RequestParam category: String, + @RequestParam(required = false) description: String?, + @RequestParam(defaultValue = "false") isActive: Boolean, + redirectAttributes: RedirectAttributes + ): String { + try { + val questionCategory = QuestionCategory.valueOf(category) + adminService.updateQuestion(questionId, content, questionCategory, description, isActive) + redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 수정되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "질문 수정에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/questions" + } + + @PostMapping("/v1/admin/questions/{questionId}/delete") + fun deleteQuestion( + @PathVariable questionId: Long, + redirectAttributes: RedirectAttributes + ): String { + try { + adminService.deleteQuestion(questionId) + redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 삭제되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "질문 삭제에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/questions" + } + + @PostMapping("/v1/admin/questions/{questionId}/toggle") + fun toggleQuestionStatus( + @PathVariable questionId: Long, + redirectAttributes: RedirectAttributes + ): String { + try { + val question = adminService.toggleQuestionStatus(questionId) + val status = if (question.isActive) "활성화" else "비활성화" + redirectAttributes.addFlashAttribute("success", "질문이 성공적으로 ${status}되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "질문 상태 변경에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/questions" + } + + // ========== 신고 관리 ========== + + /** + * 신고 목록 조회 페이지 + */ + @GetMapping("/v1/admin/reports") + fun reportList( + model: Model, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) status: String?, + @RequestParam(required = false) startDate: String?, + @RequestParam(required = false) endDate: String?, + @PageableDefault(size = 20) pageable: Pageable + ): String { + // 신고 목록 조회 + val reports = reportAdminService.getReportsWithFilter(keyword, status, startDate, endDate, pageable) + + // 통계 정보 + val stats = mapOf( + "total" to reportAdminService.getTotalReportsCount(), + "pending" to reportAdminService.getReportCountByStatus(ReportStatus.PENDING), + "inProgress" to reportAdminService.getReportCountByStatus(ReportStatus.IN_PROGRESS), + "resolved" to reportAdminService.getReportCountByStatus(ReportStatus.RESOLVED), + "dismissed" to reportAdminService.getReportCountByStatus(ReportStatus.DISMISSED), + "today" to reportAdminService.getTodayReportsCount(), + "weekly" to reportAdminService.getWeeklyReportsCount(), + "monthly" to reportAdminService.getMonthlyReportsCount() + ) + + // 신고 많이 받은 사용자 TOP 10 + val topReported = reportAdminService.getMostReportedMembers(30, 10) + + model.addAttribute("reports", reports) + model.addAttribute("stats", stats) + model.addAttribute("topReported", topReported) + model.addAttribute("statuses", ReportStatus.values()) + model.addAttribute("param", mapOf( + "keyword" to (keyword ?: ""), + "status" to (status ?: ""), + "startDate" to (startDate ?: ""), + "endDate" to (endDate ?: "") + )) + + return "reportList" + } + + /** + * 신고 상세 조회 페이지 + */ + @GetMapping("/v1/admin/reports/{reportId}") + fun reportDetail( + model: Model, + @PathVariable reportId: Long + ): String { + try { + val report = reportAdminService.getReportDetail(reportId) + + // 피신고자의 신고 이력 + val reportedMemberId = report.reported.getIdOrThrow() + val reportHistory = reportAdminService.getReportedMemberReports( + reportedMemberId, + Pageable.ofSize(10) + ) + val totalReportCount = reportAdminService.getReportedMemberReportCount(reportedMemberId) + + model.addAttribute("report", report) + model.addAttribute("reportHistory", reportHistory) + model.addAttribute("totalReportCount", totalReportCount) + model.addAttribute("statuses", ReportStatus.values()) + + return "reportDetail" + } catch (e: Exception) { + model.addAttribute("error", "신고 내역을 불러올 수 없습니다 (ID: $reportId)") + return "redirect:/v1/admin/reports" + } + } + + /** + * 신고 처리 상태 변경 + */ + @PostMapping("/v1/admin/reports/{reportId}/status") + fun updateReportStatus( + @PathVariable reportId: Long, + @RequestParam status: String, + @RequestParam(required = false) note: String?, + redirectAttributes: RedirectAttributes + ): String { + try { + val reportStatus = ReportStatus.valueOf(status) + reportAdminService.updateReportStatus(reportId, reportStatus, note) + redirectAttributes.addFlashAttribute("success", "신고 상태가 변경되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "신고 상태 변경에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/reports/$reportId" + } + + /** + * 신고 처리 완료 + */ + @PostMapping("/v1/admin/reports/{reportId}/resolve") + fun resolveReport( + @PathVariable reportId: Long, + @RequestParam(required = false) note: String?, + redirectAttributes: RedirectAttributes + ): String { + try { + reportAdminService.resolveReport(reportId, note) + redirectAttributes.addFlashAttribute("success", "신고가 처리되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "신고 처리에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/reports/$reportId" + } + + /** + * 신고 반려 + */ + @PostMapping("/v1/admin/reports/{reportId}/dismiss") + fun dismissReport( + @PathVariable reportId: Long, + @RequestParam(required = false) note: String?, + redirectAttributes: RedirectAttributes + ): String { + try { + reportAdminService.dismissReport(reportId, note) + redirectAttributes.addFlashAttribute("success", "신고가 반려되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "신고 반려에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/reports/$reportId" + } + + /** + * 회원의 거절 이력 조회 (API) + */ + @GetMapping("/v1/admin/members/{memberId}/rejection-histories") + @ResponseBody + fun getRejectionHistories( + @PathVariable memberId: Long + ): ResponseEntity> { + val member = adminService.findMember(memberId) + val histories = adminService.getRejectionHistories(memberId) + val maxRound = if (histories.isEmpty()) 0 else adminService.getMaxRejectionRound(memberId) + + val historyResponses = histories.map { history -> + codel.admin.presentation.response.RejectionHistoryResponse.from(history) + } + + val response = mapOf( + "memberId" to memberId, + "memberName" to (member.profile?.codeName ?: "이름 없음"), + "totalRejectionCount" to histories.size, + "maxRejectionRound" to maxRound, + "histories" to historyResponses + ) + + return ResponseEntity.ok(response) + } + + // ========== 표준 인증 이미지 관리 ========== + + /** + * 표준 인증 이미지 목록 페이지 + */ + @GetMapping("/v1/admin/verification-images") + fun standardImageList(model: Model): String { + val images = adminService.getAllStandardVerificationImages() + model.addAttribute("images", images) + return "verificationImageList" + } + + /** + * 표준 인증 이미지 등록 페이지 + */ + @GetMapping("/v1/admin/verification-images/new") + fun standardImageForm(model: Model): String { + return "verificationImageForm" + } + + /** + * 표준 인증 이미지 등록 처리 + */ + @PostMapping("/v1/admin/verification-images") + fun createStandardImage( + @RequestParam imageFile: org.springframework.web.multipart.MultipartFile, + @RequestParam(required = false) description: String?, + redirectAttributes: RedirectAttributes + ): String { + try { + adminService.createStandardVerificationImage(imageFile, description) + redirectAttributes.addFlashAttribute("success", "표준 인증 이미지가 성공적으로 등록되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "이미지 등록에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/verification-images" + } + + /** + * 표준 인증 이미지 활성화/비활성화 토글 + */ + @PostMapping("/v1/admin/verification-images/{imageId}/toggle") + fun toggleStandardImageStatus( + @PathVariable imageId: Long, + redirectAttributes: RedirectAttributes + ): String { + try { + val image = adminService.toggleStandardImageStatus(imageId) + val status = if (image.isActive) "활성화" else "비활성화" + redirectAttributes.addFlashAttribute("success", "표준 인증 이미지가 성공적으로 ${status}되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "상태 변경에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/verification-images" + } + + /** + * 표준 인증 이미지 삭제 + */ + @PostMapping("/v1/admin/verification-images/{imageId}/delete") + fun deleteStandardImage( + @PathVariable imageId: Long, + redirectAttributes: RedirectAttributes + ): String { + try { + adminService.deleteStandardVerificationImage(imageId) + redirectAttributes.addFlashAttribute("success", "표준 인증 이미지가 성공적으로 삭제되었습니다.") + } catch (e: Exception) { + redirectAttributes.addFlashAttribute("error", "이미지 삭제에 실패했습니다: ${e.message}") + } + return "redirect:/v1/admin/verification-images" + } +} + diff --git a/src/main/kotlin/codel/admin/presentation/request/AdminLoginRequest.kt b/src/main/kotlin/codel/admin/presentation/request/AdminLoginRequest.kt new file mode 100644 index 00000000..bd5e9ebf --- /dev/null +++ b/src/main/kotlin/codel/admin/presentation/request/AdminLoginRequest.kt @@ -0,0 +1,5 @@ +package codel.admin.presentation.request + +data class AdminLoginRequest( + val password: String, +) diff --git a/src/main/kotlin/codel/admin/presentation/request/RejectProfileRequest.kt b/src/main/kotlin/codel/admin/presentation/request/RejectProfileRequest.kt new file mode 100644 index 00000000..0ca4ad1b --- /dev/null +++ b/src/main/kotlin/codel/admin/presentation/request/RejectProfileRequest.kt @@ -0,0 +1,17 @@ +package codel.admin.presentation.request + +/** + * 프로필 거절 요청 (관리자용) + */ +data class RejectProfileRequest( + val faceImageRejections: List?, + val codeImageRejections: List? +) + +/** + * 이미지 거절 정보 + */ +data class ImageRejection( + val imageId: Long, + val reason: String +) diff --git a/src/main/kotlin/codel/admin/presentation/response/RejectionHistoryResponse.kt b/src/main/kotlin/codel/admin/presentation/response/RejectionHistoryResponse.kt new file mode 100644 index 00000000..3f99fe9f --- /dev/null +++ b/src/main/kotlin/codel/admin/presentation/response/RejectionHistoryResponse.kt @@ -0,0 +1,49 @@ +package codel.admin.presentation.response + +import codel.member.domain.ImageType +import codel.member.domain.RejectionHistory +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * 거절 이력 응답 DTO + */ +data class RejectionHistoryResponse( + val id: Long, + val rejectionRound: Int, + val imageType: String, + val imageUrl: String, + val imageOrder: Int, + val reason: String, + val rejectedAt: String +) { + companion object { + private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + fun from(history: RejectionHistory): RejectionHistoryResponse { + return RejectionHistoryResponse( + id = history.id, + rejectionRound = history.rejectionRound, + imageType = when (history.imageType) { + ImageType.FACE_IMAGE -> "얼굴 이미지" + ImageType.CODE_IMAGE -> "코드 이미지" + }, + imageUrl = history.imageUrl, + imageOrder = history.imageOrder + 1, // 0-based를 1-based로 변환 + reason = history.reason, + rejectedAt = history.rejectedAt.format(formatter) + ) + } + } +} + +/** + * 회원의 전체 거절 이력 응답 + */ +data class MemberRejectionHistoriesResponse( + val memberId: Long, + val memberName: String, + val totalRejectionCount: Int, + val maxRejectionRound: Int, + val histories: List +) diff --git a/src/main/kotlin/codel/auth/TokenProvider.kt b/src/main/kotlin/codel/auth/TokenProvider.kt new file mode 100644 index 00000000..d08a4995 --- /dev/null +++ b/src/main/kotlin/codel/auth/TokenProvider.kt @@ -0,0 +1,67 @@ +package codel.auth + +import codel.auth.exception.AuthException +import codel.member.domain.Member +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import java.security.Key +import java.util.* + +@Component +class TokenProvider( + @Value("\${security.jwt.token.secret-key}") + private val secretKey: String, + @Value("\${security.jwt.token.expire-length}") + private val validityInMilliseconds: Long, +) { + companion object { + private const val MEMBER_ID_CLAIM_KEY = "id" + } + + private val key: Key + get() = Keys.hmacShaKeyFor(secretKey.toByteArray()) + + fun provide(member: Member): String { + val now = Date() + val validity = Date(now.time + validityInMilliseconds) + + return Jwts + .builder() + .claim(MEMBER_ID_CLAIM_KEY, member.id) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact() + } + + fun validateToken(token: String): Boolean = + runCatching { + val claims = getPayload(token) + validateExpireTime(claims) + }.isSuccess + + private fun getPayload(token: String): Claims = + try { + Jwts + .parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .body + } catch (e: Exception) { + throw AuthException(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다.") + } + + private fun validateExpireTime(claims: Claims) { + if (claims.expiration.before(Date())) { + throw AuthException(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다.") + } + } + + fun extractMemberId(token: String): String = getPayload(token)[MEMBER_ID_CLAIM_KEY].toString() +} diff --git a/src/main/kotlin/codel/auth/business/AuthService.kt b/src/main/kotlin/codel/auth/business/AuthService.kt new file mode 100644 index 00000000..659ec2ea --- /dev/null +++ b/src/main/kotlin/codel/auth/business/AuthService.kt @@ -0,0 +1,12 @@ +package codel.auth.business + +import codel.auth.TokenProvider +import codel.member.domain.Member +import org.springframework.stereotype.Service + +@Service +class AuthService( + private val tokenProvider: TokenProvider, +) { + fun provideToken(member: Member): String = tokenProvider.provide(member) +} diff --git a/src/main/kotlin/codel/auth/exception/AuthException.kt b/src/main/kotlin/codel/auth/exception/AuthException.kt new file mode 100644 index 00000000..d545fd44 --- /dev/null +++ b/src/main/kotlin/codel/auth/exception/AuthException.kt @@ -0,0 +1,9 @@ +package codel.auth.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class AuthException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/block/business/BlockService.kt b/src/main/kotlin/codel/block/business/BlockService.kt new file mode 100644 index 00000000..bab2bedf --- /dev/null +++ b/src/main/kotlin/codel/block/business/BlockService.kt @@ -0,0 +1,98 @@ +package codel.block.business + +import codel.block.domain.BlockMemberRelation +import codel.block.exception.BlockException +import codel.block.infrastructure.BlockMemberRelationJpaRepository +import codel.chat.business.ChatService +import codel.chat.domain.ChatRoomStatus +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.presentation.response.SavedChatDto +import codel.member.domain.Member +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.signal.domain.SignalStatus +import codel.signal.infrastructure.SignalJpaRepository +import jakarta.transaction.Transactional +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service + +@Service +@Transactional +class BlockService( + val memberJpaRepository: MemberJpaRepository, + val blockMemberRelationJpaRepository: BlockMemberRelationJpaRepository, + val signalJpaRepository: SignalJpaRepository, + val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + val chatService: ChatService +) { + + fun blockMember(blocker: Member, blockedMemberId: Long): SavedChatDto? { + if (blocker.getIdOrThrow() == blockedMemberId) { + throw BlockException(HttpStatus.BAD_REQUEST, "자기 자신을 차단할 수 없습니다.") + } + + val blockedMemberIds = blockMemberRelationJpaRepository.findBlockMembersBy(blocker.getIdOrThrow()) + .map { it.blockedMember.id } + + val blockedMember = memberJpaRepository.findById(blockedMemberId) + .orElseThrow { MemberException(HttpStatus.BAD_REQUEST, "차단할 회원을 찾을 수 없습니다.") } + + if (blockedMemberIds.contains(blockedMember.getIdOrThrow())) { + throw BlockException(HttpStatus.BAD_REQUEST, "이미 차단한 회원입니다.") + } + + // 1. 시그널 확인 + val signalFromBlocker = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(blocker, blockedMember) + val signalToBlocker = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(blockedMember, blocker) + + // 2. 채팅방 확인 + val chatRoom = chatService.findChatRoomBetweenMembers(blocker, blockedMember) + + // 3. 차단 관계 저장 + val blockMemberRelation = BlockMemberRelation(blockerMember = blocker, blockedMember = blockedMember) + blockMemberRelationJpaRepository.save(blockMemberRelation) + + return when { + // 채팅방이 있는 경우 -> 시스템 메시지 + WebSocket 전송 + chatRoom != null -> { + if (chatRoom.status != ChatRoomStatus.DISABLED) { + chatRoom.closeConversation() + // 시스템 메시지 생성 및 응답 구성 + chatService.createCloseConversationMessage(chatRoom, blocker, blockedMember) + } else { + null + } + } + // 시그널 전송 + 채팅방 없는 경우 -> 시그널 REJECT 상태로 처리 + (signalFromBlocker != null || signalToBlocker != null) -> { + signalFromBlocker?.let { + if (it.senderStatus != SignalStatus.REJECTED) { + it.reject() + } + } + signalToBlocker?.let { + if (it.receiverStatus != SignalStatus.REJECTED) { + it.reject() + } + } + null + } + // 시그널 미전송 + 채팅방 없는 경우 -> 차단만 적용 + else -> null + } + } + + fun unBlockMember(blocker: Member, blockedMemberId: Long) { + if (blocker.getIdOrThrow() == blockedMemberId) { + throw BlockException(HttpStatus.BAD_REQUEST, "자기 자신을 차단 해제할 수 없습니다.") + } + + val findBlockRelation = blockMemberRelationJpaRepository.findByBlockerMemberAndBlockedMember( + blocker.getIdOrThrow(), + blockedMemberId + ) + ?: throw BlockException(HttpStatus.BAD_REQUEST, "차단한 적이 없는 회원입니다.") + + findBlockRelation.unblock() + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/block/domain/BlockMemberRelation.kt b/src/main/kotlin/codel/block/domain/BlockMemberRelation.kt new file mode 100644 index 00000000..8cd8bc64 --- /dev/null +++ b/src/main/kotlin/codel/block/domain/BlockMemberRelation.kt @@ -0,0 +1,35 @@ +package codel.block.domain + +import codel.block.exception.BlockException +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.ManyToOne +import org.springframework.http.HttpStatus + +@Entity +class BlockMemberRelation( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + @ManyToOne + var blockerMember: Member, + @ManyToOne + var blockedMember: Member, + + @Enumerated(EnumType.STRING) + var status : BlockStatus = BlockStatus.BLOCKED, +) : BaseTimeEntity() { + + fun unblock() { + if(status == BlockStatus.UNBLOCKED){ + throw BlockException(HttpStatus.BAD_REQUEST, "이미 차단해제된 회원입니다.") + } + status = BlockStatus.UNBLOCKED + } +} diff --git a/src/main/kotlin/codel/block/domain/BlockStatus.kt b/src/main/kotlin/codel/block/domain/BlockStatus.kt new file mode 100644 index 00000000..501a7524 --- /dev/null +++ b/src/main/kotlin/codel/block/domain/BlockStatus.kt @@ -0,0 +1,6 @@ +package codel.block.domain + +enum class BlockStatus(statusName : String) { + BLOCKED("차단"), + UNBLOCKED("차단해제"), +} diff --git a/src/main/kotlin/codel/block/exception/BlockException.kt b/src/main/kotlin/codel/block/exception/BlockException.kt new file mode 100644 index 00000000..2acb467b --- /dev/null +++ b/src/main/kotlin/codel/block/exception/BlockException.kt @@ -0,0 +1,9 @@ +package codel.block.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class BlockException( + status : HttpStatus, + message : String, +) : CodelException(status, message) \ No newline at end of file diff --git a/src/main/kotlin/codel/block/infrastructure/BlockMemberRelationJpaRepository.kt b/src/main/kotlin/codel/block/infrastructure/BlockMemberRelationJpaRepository.kt new file mode 100644 index 00000000..c7a364f1 --- /dev/null +++ b/src/main/kotlin/codel/block/infrastructure/BlockMemberRelationJpaRepository.kt @@ -0,0 +1,45 @@ +package codel.block.infrastructure + +import codel.block.domain.BlockMemberRelation +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface BlockMemberRelationJpaRepository : JpaRepository { + + @Query("SELECT bmr FROM BlockMemberRelation bmr WHERE bmr.blockerMember.id = :blockerId AND bmr.status = 'BLOCKED'") + fun findBlockMembersBy(blockerId: Long) : List + + @Query("SELECT bmr FROM BlockMemberRelation bmr WHERE bmr.blockedMember.id = :blockedId AND bmr.status = 'BLOCKED'") + fun findBlockerMembersTo(blockedId: Long) : List + + @Query("SELECT bmr FROM BlockMemberRelation bmr WHERE bmr.blockerMember.id = :blockerMemberId AND bmr.blockedMember.id = :blockedMemberId") + fun findByBlockerMemberAndBlockedMember(blockerMemberId : Long, blockedMemberId: Long) : BlockMemberRelation? + + @Query( + """ + SELECT bmr.blockedMember.id FROM BlockMemberRelation bmr + WHERE bmr.blockerMember.id = :blockerId + AND bmr.status = 'BLOCKED' + AND bmr.createdAt < :beforeTime + """ + ) + fun findBlockedMemberIdByMeBeforeTime( + @Param("blockerId") blockerId: Long, + @Param("beforeTime") beforeTime: LocalDateTime + ): List + + @Query( + """ + SELECT bmr.blockerMember.id FROM BlockMemberRelation bmr + WHERE bmr.blockedMember.id = :blockedId + AND bmr.status = 'BLOCKED' + AND bmr.createdAt < :beforeTime + """ + ) + fun findBlockMembersByOtherBeforeTime( + @Param("blockedId") blockedId: Long, + @Param("beforeTime") beforeTime: LocalDateTime + ): List +} \ No newline at end of file diff --git a/src/main/kotlin/codel/block/presentation/BlockController.kt b/src/main/kotlin/codel/block/presentation/BlockController.kt new file mode 100644 index 00000000..b6b9f611 --- /dev/null +++ b/src/main/kotlin/codel/block/presentation/BlockController.kt @@ -0,0 +1,87 @@ +package codel.block.presentation + +import codel.block.business.BlockService +import codel.block.presentation.request.BlockMemberRequest +import codel.block.presentation.swagger.BlockControllerSwagger +import codel.config.argumentresolver.LoginMember +import codel.member.business.MemberService +import codel.member.domain.Member +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.http.ResponseEntity +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@RestController +@RequestMapping("/v1/block") +class BlockController( + val blockService: BlockService, + val memberService : MemberService, + val messagingTemplate: SimpMessagingTemplate, + val asyncNotificationService: IAsyncNotificationService, +) : BlockControllerSwagger { + + @PostMapping + override fun blockMember( + @LoginMember blocker: Member, + @RequestBody blockMemberRequest: BlockMemberRequest + ): ResponseEntity { + val savedChatDto = blockService.blockMember(blocker, blockMemberRequest.blockedMemberId) + + // 채팅방이 있었고 시스템 메시지가 생성된 경우에만 WebSocket 전송 + savedChatDto?.let { responseDto -> + // 상대방에게는 읽지 않은 수가 증가된 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${responseDto.partner.id}", + responseDto.partnerChatRoomResponse, + ) + + // 발송자에게는 본인 기준 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${blocker.id}", + responseDto.requesterChatRoomResponse, + ) + + // 채팅방 구독자들에게 실시간 메시지 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/${responseDto.requesterChatRoomResponse.chatRoomId}", + responseDto.chatResponse + ) + } + + // 디스코드 알림은 채팅방 존재 여부와 관계없이 항상 전송 + val blockedMember = memberService.findMember(blockMemberRequest.blockedMemberId) + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = blocker.getProfileOrThrow().toString(), + title = "🚨 차단 접수 알림", + body = buildString { + append("👮‍♀️ 차단자: ${blocker.getProfileOrThrow().getCodeNameOrThrow()}\n") + append("🎯 피차단자: ${blockedMember.getProfileOrThrow().getCodeNameOrThrow()}\n") + append("🗓 차단 시각: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}") + }, + ), + ) + + return ResponseEntity.ok().build() + } + + @DeleteMapping("/{memberId}") + override fun unBlockMember( + @LoginMember blocker: Member, + @PathVariable memberId: Long + ): ResponseEntity { + blockService.unBlockMember(blocker, memberId) + return ResponseEntity.noContent().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/block/presentation/request/BlockMemberRequest.kt b/src/main/kotlin/codel/block/presentation/request/BlockMemberRequest.kt new file mode 100644 index 00000000..f46d166a --- /dev/null +++ b/src/main/kotlin/codel/block/presentation/request/BlockMemberRequest.kt @@ -0,0 +1,5 @@ +package codel.block.presentation.request + +class BlockMemberRequest( + val blockedMemberId : Long +) \ No newline at end of file diff --git a/src/main/kotlin/codel/block/presentation/swagger/BlockControllerSwggger.kt b/src/main/kotlin/codel/block/presentation/swagger/BlockControllerSwggger.kt new file mode 100644 index 00000000..4f0f830c --- /dev/null +++ b/src/main/kotlin/codel/block/presentation/swagger/BlockControllerSwggger.kt @@ -0,0 +1,49 @@ +package codel.block.presentation.swagger + +import codel.block.presentation.request.BlockMemberRequest +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody + +@Tag(name = "Block", description = "차단 관련 API") +interface BlockControllerSwagger { + + @Operation( + summary = "회원 차단", + description = "특정 회원을 차단합니다. 이미 차단했거나 자기 자신을 차단할 수는 없습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "차단 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 (자기 자신 차단, 중복 차단 등)"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun blockMember( + @Parameter(hidden = true) @LoginMember blocker: Member, + @RequestBody blockMemberRequest: BlockMemberRequest + ): ResponseEntity + + @Operation( + summary = "회원 차단 해제", + description = "특정 회원을 차단 해제합니다. 이미 차단했거나 자기 자신을 차단할 수는 없습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "차단 해제 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 (자기 자신 차단, 중복 차단 등)"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun unBlockMember( + @Parameter(hidden = true) @LoginMember blocker: Member, + @PathVariable memberId : Long, + ): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/codel/chat/business/ChatService.kt b/src/main/kotlin/codel/chat/business/ChatService.kt new file mode 100644 index 00000000..80ae7dce --- /dev/null +++ b/src/main/kotlin/codel/chat/business/ChatService.kt @@ -0,0 +1,810 @@ +package codel.chat.business + +import codel.block.domain.BlockMemberRelation +import codel.block.infrastructure.BlockMemberRelationJpaRepository +import codel.chat.domain.Chat +import codel.chat.domain.ChatContentType +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomMember +import codel.chat.domain.ChatRoomStatus +import codel.chat.domain.ChatSenderType +import codel.chat.exception.ChatException +import codel.chat.infrastructure.ChatJpaRepository +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.presentation.request.ChatSendRequest +import codel.chat.presentation.response.ChatResponse +import codel.chat.presentation.response.ChatRoomEventType +import codel.chat.presentation.response.ChatRoomResponse +import codel.chat.presentation.response.InitialChatRoomResult +import codel.chat.presentation.response.SavedChatDto +import codel.chat.presentation.response.QuestionSendResult +import codel.chat.repository.ChatRepository +import codel.chat.repository.ChatRoomRepository +import codel.config.Loggable +import codel.member.domain.Member +import codel.member.domain.MemberRepository +import codel.member.infrastructure.MemberJpaRepository +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationDataType +import codel.notification.domain.NotificationType +import codel.signal.infrastructure.SignalJpaRepository +import codel.question.business.QuestionService +import codel.question.domain.Question +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime +import codel.common.util.DateTimeFormatter as CodelDateTimeFormatter + +@Transactional +@Service +class ChatService( + private val chatRoomRepository: ChatRoomRepository, + private val chatRepository: ChatRepository, + private val memberRepository: MemberRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val signalJpaRepository: SignalJpaRepository, + private val chatRoomJpaRepository: ChatRoomJpaRepository, + private val chatJpaRepository: ChatJpaRepository, + private val questionService: QuestionService, + private val codeUnlockService: CodeUnlockService, + private val memberJpaRepository: MemberJpaRepository, + private val asyncNotificationService: IAsyncNotificationService, + private val blockMemberRelationJpaRepository: BlockMemberRelationJpaRepository +) : Loggable { + + + fun createInitialChatRoom( + approver: Member, + sender: Member, + responseOfApproverQuestion: String + ): InitialChatRoomResult { + // 1. 채팅방 생성 + val managedApprover = memberRepository.findMemberWithProfileAndQuestion( + approver.getIdOrThrow() + ) ?: throw ChatException(HttpStatus.NOT_FOUND, "approver를 찾을 수 없습니다.") + + val managedSender = memberRepository.findMemberWithProfileAndQuestion( + sender.getIdOrThrow() + ) ?: throw ChatException(HttpStatus.NOT_FOUND, "sender를 찾을 수 없습니다.") + + val newChatRoom = ChatRoom() + val savedChatRoom = chatRoomJpaRepository.save(newChatRoom) + + // 2. 멤버 등록 + val approverMember = ChatRoomMember(chatRoom = savedChatRoom, member = managedApprover) + val senderMember = ChatRoomMember(chatRoom = savedChatRoom, member = managedSender) + val savedApprover = chatRoomMemberJpaRepository.save(approverMember) + val savedSender = chatRoomMemberJpaRepository.save(senderMember) + + // 3. 메시지 생성 + saveSystemMessages(savedChatRoom, savedApprover) + saveUserMessages( + savedChatRoom, + savedApprover, + savedSender, + managedApprover, + managedSender, + responseOfApproverQuestion + ) + + // 4. 양쪽 대표 질문을 사용된 것으로 표시 + val approverRepresentativeQuestion = managedApprover.getProfileOrThrow().getRepresentativeQuestionOrThrow() + val senderRepresentativeQuestion = managedSender.getProfileOrThrow().getRepresentativeQuestionOrThrow() + + questionService.markQuestionAsUsed(savedChatRoom.getIdOrThrow(), approverRepresentativeQuestion, managedSender) +// questionService.markQuestionAsUsed(savedChatRoom.getIdOrThrow(), senderRepresentativeQuestion, managedApprover) + + // 5. 생성된 채팅방의 읽지 않은 메시지 수 계산 (각자 기준) + val approverUnReadCount = chatRepository.getUnReadMessageCount(savedChatRoom, managedApprover) + val senderUnReadCount = chatRepository.getUnReadMessageCount(savedChatRoom, managedSender) + + // 6. 각 사용자별 ChatRoomResponse 생성 + val approverChatRoomResponse = ChatRoomResponse.toResponse( + newChatRoom, managedApprover, null, managedSender, approverUnReadCount + ) + + val senderChatRoomResponse = ChatRoomResponse.toResponse( + newChatRoom, managedSender, null, managedApprover, senderUnReadCount + ) + + return InitialChatRoomResult( + approverChatRoomResponse = approverChatRoomResponse, + senderChatRoomResponse = senderChatRoomResponse + ) + } + + private fun saveSystemMessages(chatRoom: ChatRoom, from: ChatRoomMember) { + val now = LocalDateTime.now() + val todayFormatted = CodelDateTimeFormatter.getTodayInLocalFormat("ko") + + val systemMessages = listOf( + Chat( + chatRoom = chatRoom, + fromChatRoomMember = from, + message = "코드 매칭에 성공했어요!", + sentAt = now, + senderType = ChatSenderType.SYSTEM, + chatContentType = ChatContentType.MATCHED + ), + Chat( + chatRoom = chatRoom, + fromChatRoomMember = from, + message = "✨ 코드 대화가 시작되었습니다.\n" + + "이어서 질문에 답하며 대화를 시작해보세요!\n\n" + + "\uD83D\uDD13 프로필 해제 안내\n" + + "상대의 숨겨진 프로필이 궁금하다면?\n[ ] 버튼을 눌러 상대의 숨겨진 히든 코드프로필 해제를 요청할 수 있어요.\n\n" + + "❓ 혹시 아직 어색한가요?\n위에 있는 [ ] 버튼을 확인해보세요.\n" + + "두 분의 공통 관심사에 맞춘 질문을 CODE가 추천해드립니다.\n\n ✨ 인연의 시작, CODE가 함께할게요.", + sentAt = now, + senderType = ChatSenderType.SYSTEM, + chatContentType = ChatContentType.ONBOARDING + ), + Chat( + chatRoom = chatRoom, + fromChatRoomMember = from, + message = todayFormatted, + sentAt = now, + senderType = ChatSenderType.SYSTEM, + chatContentType = ChatContentType.TIME + ) + ) + + chatJpaRepository.saveAll(systemMessages) + } + + private fun saveUserMessages( + chatRoom: ChatRoom, + fromApprover: ChatRoomMember, + fromSender: ChatRoomMember, + approver: Member, + sender: Member, + responseOfApproverQuestion: String + ) { + val now = LocalDateTime.now() + val approverProfile = approver.getProfileOrThrow() + val senderProfile = sender.getProfileOrThrow() + + val userMessages = listOf( + // 1. 승인자 질문 + Chat( + chatRoom = chatRoom, + fromChatRoomMember = fromApprover, + message = "${approverProfile.getCodeNameOrThrow()}님의 코드 질문\n💭 ${approverProfile.getRepresentativeQuestionOrThrow().content}", + sentAt = now, + senderType = ChatSenderType.SYSTEM, + chatContentType = ChatContentType.QUESTION + ), + // 2. 승인자 대답 + Chat( + chatRoom = chatRoom, + fromChatRoomMember = fromApprover, + message = approverProfile.getRepresentativeAnswerOrThrow(), + sentAt = now, + senderType = ChatSenderType.USER, + chatContentType = ChatContentType.TEXT + ), + // 3. 발송자 대답 + Chat( + chatRoom = chatRoom, + fromChatRoomMember = fromSender, + message = responseOfApproverQuestion, + sentAt = now, + senderType = ChatSenderType.USER, + chatContentType = ChatContentType.TEXT + ), + + ) + + val savedMessages = chatJpaRepository.saveAll(userMessages) + chatRoom.updateRecentChat(savedMessages.last()) + } + + + @Transactional(readOnly = true) + fun getChatRooms( + requester: Member, + pageable: Pageable, + ): Page { + // 활성 상태인 채팅방만 조회 + val activeChatRooms = chatRoomRepository.findActiveChatRoomsByMember(requester, pageable) + + return activeChatRooms.map { chatRoomInfo -> + // 상대방 상태에 따른 읽지 않은 메시지 수 계산 + val unReadCount = calculateUnreadCount( + chatRoomInfo.chatRoom, + requester, + chatRoomInfo.partnerChatRoomMember + ) + + // unlockInfo 추가 + val unlockInfo = codeUnlockService.getUnlockInfo(chatRoomInfo.chatRoom, requester) + + ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom = chatRoomInfo.chatRoom, + requester = requester, + lastReadChatId = chatRoomInfo.requesterChatRoomMember.lastReadChat?.getIdOrThrow(), + partner = chatRoomInfo.partner, + unReadMessageCount = unReadCount, + unlockInfo = unlockInfo + ) + } + } + + /** + * 상대방 상태에 따른 읽지 않은 메시지 수 계산 + */ + private fun calculateUnreadCount( + chatRoom: ChatRoom, + requester: Member, + partnerChatRoomMember: ChatRoomMember? + ): Int { + return if (partnerChatRoomMember?.hasLeft() == true) { + 0 // 차단된 경우만 0 + } else { + // 파트너가 나간 경우에도 일반적인 읽지 않은 메시지 수와 동일 + // 왜냐하면 파트너가 나간 이후로는 새 메시지가 없기 때문 + chatRepository.getUnReadMessageCount(chatRoom, requester) + } + } + + fun saveChat( + chatRoomId: Long, + requester: Member, + chatSendRequest: ChatSendRequest, + ): SavedChatDto { + // 메시지 전송 가능 여부 확인 + validateCanSendMessage(chatRoomId, requester) + + val chatRoom = chatRoomRepository.findChatRoomById(chatRoomId) + + // 날짜 변경 확인 및 날짜 메시지 추가 + checkAndSaveDateMessageIfNeeded(chatRoom, chatSendRequest.recentChatTime) + + val savedChat = chatRepository.saveChat(chatRoomId, requester, chatSendRequest) + chatRoom.updateRecentChat(savedChat) + + val partner = chatRoomRepository.findPartner(chatRoomId, requester) + + // FCM 푸시 알림 전송 (상대방에게) + sendChatNotification(chatRoomId, partner, requester, savedChat, chatSendRequest.message) + + val unlockInfoOfRequester = codeUnlockService.getUnlockInfo(chatRoom, requester) + val unlockInfoOfPartner = codeUnlockService.getUnlockInfo(chatRoom, partner) + + + // 발송자와 수신자의 읽지 않은 메시지 수를 각각 계산 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, requester) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + + val chatResponse = ChatResponse.toResponse(requester, savedChat) + + // 발송자용 채팅방 응답 (본인 기준 읽지 않은 수) + val requesterChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, requester, savedChat.getIdOrThrow(), requester, + requesterUnReadCount, + unlockInfoOfRequester + ) + + // 수신자용 채팅방 응답 (상대방 기준 읽지 않은 수 - 새 메시지로 인해 증가) + val partnerChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, partner, null, requester, + partnerUnReadCount, + unlockInfoOfPartner + ) + + return SavedChatDto(partner, requesterChatRoomResponse, partnerChatRoomResponse, chatResponse) + } + + @Transactional(readOnly = true) + fun getChats( + chatRoomId: Long, + lastChatId: Long?, + requester: Member, + pageable: Pageable, + ): Page { + val pagedChats = chatRepository.findNextChats(chatRoomId, lastChatId, pageable) + return pagedChats.map { chat -> ChatResponse.toResponse(requester, chat) } + } + + @Transactional(readOnly = true) + fun getPreviousChats( + chatRoomId: Long, + lastChatId: Long?, + requester: Member, + pageable: Pageable, + ): Page { + val pagedChats = chatRepository.findPrevChats(chatRoomId, lastChatId, pageable) + return pagedChats.map { chat -> ChatResponse.toResponse(requester, chat) } + } + + fun updateLastChat( + chatRoomId: Long, + lastReadChatId: Long, + requester: Member, + ) { + val lastChat = chatRepository.findChat(lastReadChatId) + chatRepository.upsertLastChat(chatRoomId, requester, lastChat) + + // 읽음 처리 후 상대방에게도 업데이트된 정보 전송 (읽지 않은 수 감소 반영) + val chatRoom = chatRoomRepository.findChatRoomById(chatRoomId) + val partner = chatRoomRepository.findPartner(chatRoomId, requester) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + + val updatedChatRoomResponse = ChatRoomResponse.toResponse( + chatRoom, partner, + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoomId, partner)?.lastReadChat?.getIdOrThrow(), + requester, + partnerUnReadCount + ) + + //TODO WebSocket으로 상대방에게 업데이트 전송 (Spring Event 등을 활용할 수도 있음) + // 이 부분은 Controller나 별도 이벤트 핸들러에서 처리하는 것이 좋습니다. + } + + fun updateUnlockChatRoom(requester: Member, chatRoomId: Long): SavedChatDto { + val chatRoom = chatRoomRepository.findChatRoomById(chatRoomId) + + val savedChat = chatJpaRepository.save( + Chat.createSystemMessage( + chatRoom = chatRoom, + message = "코드해제 요청이 왔습니다.", + chatContentType = ChatContentType.UNLOCKED_REQUEST + ) + ) + + val partner = chatRoomRepository.findPartner(chatRoom.getIdOrThrow(), requester) + + // 코드 해제 요청 알림 전송 + sendCodeUnlockNotification(partner, requester) + + // 발송자와 수신자의 읽지 않은 메시지 수를 각각 계산 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, requester) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + + val chatResponse = ChatResponse.toResponse(requester, savedChat) + val unlockInfoOfRequester = codeUnlockService.getUnlockInfo(chatRoom, requester) + val unlockInfoOfPartner = codeUnlockService.getUnlockInfo(chatRoom, partner) + + // 발송자용 채팅방 응답 + val requesterChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, requester, + chatRoomMemberJpaRepository.findByChatRoomIdAndMember( + chatRoom.getIdOrThrow(), + requester + )?.lastReadChat?.getIdOrThrow(), + partner, + requesterUnReadCount, + unlockInfoOfRequester + ) + + // 수신자용 채팅방 응답 (읽지 않은 수 증가) + val partnerChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, partner, null, requester, + partnerUnReadCount, + unlockInfoOfPartner + ) + + return SavedChatDto(partner, requesterChatRoomResponse, partnerChatRoomResponse, chatResponse) + } + + private fun sendCodeUnlockNotification(receiver: Member, requester: Member) { + receiver.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "${requester.getProfileOrThrow().getCodeNameOrThrow()}님이 코드 해제를 요청했어요 🔐", + body = "상대방의 프로필을 확인해보세요!" + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 코드 해제 요청 알림 전송 성공 - 수신자: ${receiver.getIdOrThrow()}, 요청자: ${requester.getIdOrThrow()}" } + } else { + log.warn { "❌ 코드 해제 요청 알림 전송 실패 - 수신자: ${receiver.getIdOrThrow()}, 요청자: ${requester.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 코드 해제 요청 알림 전송 예외 발생 - 수신자: ${receiver.getIdOrThrow()}, 요청자: ${requester.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 코드 해제 요청 알림을 전송하지 않음 - 수신자: ${receiver.getIdOrThrow()}" } + } + } + + /** + * 채팅 메시지 전송 알림 + */ + private fun sendChatNotification( + chatRoomId: Long, + receiver: Member, + sender: Member, + savedChat: Chat, + message: String + ) { + receiver.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "${sender.getProfileOrThrow().getCodeNameOrThrow()}", + body = message, + data = mapOf( + "type" to NotificationDataType.CHAT.value, + "chatRoomId" to chatRoomId.toString(), + "lastReadChatId" to savedChat.getIdOrThrow().toString() + ) + ) + + // 비동기 알림 전송 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 채팅 메시지 알림 전송 성공 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}" } + } else { + log.warn { "❌ 채팅 메시지 알림 전송 실패 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 채팅 메시지 알림 전송 예외 발생 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}" } + null + } + } ?: run { + log.debug { "ℹ️ FCM 토큰이 없어 채팅 메시지 알림을 전송하지 않음 - 수신자: ${receiver.getIdOrThrow()}" } + } + } + + /** + * 랜덤 질문을 채팅방에 전송 + */ + fun sendRandomQuestion(chatRoomId: Long, requester: Member): QuestionSendResult { + // 1. 채팅방 검증 (채팅 도메인 책임) + val chatRoom = chatRoomJpaRepository.findById(chatRoomId) + .orElseThrow { ChatException(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다.") } + + validateChatRoomMember(chatRoomId, requester) + val partner = findPartner(chatRoomId, requester) + + // 2. 질문 선택 (질문 도메인에 위임) + val availableQuestions = questionService.findUnusedQuestionsByChatRoom(chatRoomId) + if (availableQuestions.isEmpty()) { + throw ChatException(HttpStatus.NO_CONTENT, "더 이상 사용할 수 있는 질문이 없습니다.") + } + val selectedQuestion = questionService.selectRandomQuestion(availableQuestions) + + // 3. 질문 사용 표시 (질문 도메인에 위임) + questionService.markQuestionAsUsed(chatRoomId, selectedQuestion, requester) + codeUnlockService.getUnlockInfo(chatRoom, requester) + + // 4. 채팅 메시지 생성 (채팅 도메인 책임) + val savedChat = createQuestionSystemMessage(chatRoom, selectedQuestion, requester) + chatRoom.updateRecentChat(savedChat) + + return buildQuestionSendResult(requester, partner, savedChat) + } + + /** + * 채팅방 멤버 권한 검증 + */ + private fun validateChatRoomMember(chatRoomId: Long, member: Member) { + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoomId, member) + ?: throw ChatException(HttpStatus.FORBIDDEN, "해당 채팅방에 접근할 권한이 없습니다.") + } + + /** + * 질문 시스템 메시지 생성 + */ + private fun createQuestionSystemMessage( + chatRoom: ChatRoom, + question: Question, + requester: Member + ): Chat { + val message = "${requester.getProfileOrThrow().codeName}님의 코드질문\n💭 ${question.content}" + + // 요청자의 ChatRoomMember 찾기 + val requesterChatRoomMember = + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoom.getIdOrThrow(), requester) + ?: throw ChatException(HttpStatus.BAD_REQUEST, "채팅방 멤버를 찾을 수 없습니다.") + + val systemMessage = Chat( + chatRoom = chatRoom, + fromChatRoomMember = requesterChatRoomMember, // null 대신 실제 멤버 할당 + message = message, + senderType = ChatSenderType.SYSTEM, + chatContentType = ChatContentType.QUESTION, + sentAt = LocalDateTime.now() + ) + + return chatJpaRepository.save(systemMessage) + } + + /** + * 질문 전송 결과 구성 + */ + private fun buildQuestionSendResult(requester: Member, partner: Member, savedChat: Chat): QuestionSendResult { + val chatRoom = savedChat.chatRoom + + // 각자의 읽지 않은 메시지 수 계산 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, requester) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + val unlockInfoRequester = codeUnlockService.getUnlockInfo(chatRoom, requester) + val unlockInfoPartner = codeUnlockService.getUnlockInfo(chatRoom, partner) + + // 발송자용 채팅방 응답 + val requesterChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, + requester, + savedChat.getIdOrThrow(), + partner, + requesterUnReadCount, + unlockInfoRequester + ) + + // 수신자용 채팅방 응답 (읽지 않은 수 증가) + val partnerChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, + partner, + null, // 상대방은 아직 읽지 않았으므로 null + requester, + partnerUnReadCount, + unlockInfoPartner + ) + + return QuestionSendResult( + chatResponse = ChatResponse.toResponse(requester, savedChat), + partner = partner, + requesterChatRoomResponse = requesterChatRoomResponse, + partnerChatRoomResponse = partnerChatRoomResponse + ) + } + + /** + * 채팅방 나가기 + */ + fun leaveChatRoom(chatRoomId: Long, requester: Member): ChatRoomResponse { + val chatRoomMember = chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoomId, requester) + ?: throw ChatException(HttpStatus.BAD_REQUEST, "해당 채팅방의 멤버가 아닙니다.") + + // 이미 나간 상태인지 확인 + if (chatRoomMember.hasLeft()) { + throw ChatException(HttpStatus.BAD_REQUEST, "이미 나간 채팅방입니다.") + } + + // 개별 사용자 상태 변경 + chatRoomMember.leave() + + + val unlockInfoOfRequester = codeUnlockService.getUnlockInfo(chatRoomMember.chatRoom, requester) + + // 발송자와 수신자의 읽지 않은 메시지 수를 각각 계산 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoomMember.chatRoom, requester) + + return ChatRoomResponse.toResponseWithRemove( + chatRoomMember.chatRoom, ChatRoomEventType.REMOVED, requester, null, requester, + requesterUnReadCount, + unlockInfoOfRequester + ) + + } + + fun closeConversation(chatRoomId: Long, requester: Member): SavedChatDto { + // 1. 채팅방 존재 확인 + val chatRoom = chatRoomJpaRepository.findById(chatRoomId) + .orElseThrow { ChatException(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다.") } + + // 2. 상대방 찾기 + val partner = chatRoomRepository.findPartner(chatRoomId, requester) + + // 3. 상대방 차단 처리 (직접 처리 - 순환 참조 방지) + saveBlockRelationIfNotExists(requester, partner) + chatRoom.closeConversation() + + // 4. 시스템 메시지 추가 및 WebSocket 응답 생성 + return createCloseConversationMessage(chatRoom, requester, partner) + } + + /** + * 차단 관계 저장 (이미 차단한 경우 무시) + * 순환 참조 방지를 위해 ChatService에서 직접 처리 + */ + private fun saveBlockRelationIfNotExists(blocker: Member, blocked: Member) { + val existingBlock = blockMemberRelationJpaRepository.findByBlockerMemberAndBlockedMember( + blocker.getIdOrThrow(), + blocked.getIdOrThrow() + ) + + if (existingBlock == null) { + val blockRelation = BlockMemberRelation( + blockerMember = blocker, + blockedMember = blocked + ) + blockMemberRelationJpaRepository.save(blockRelation) + } + } + + /** + * 대화 종료 시스템 메시지 생성 및 응답 구성 (공통 메서드) + * 신고, 차단, 대화 종료에서 재사용 + */ + fun createCloseConversationMessage( + chatRoom: ChatRoom, + requester: Member, + partner: Member + ): SavedChatDto { + // 1. 시스템 메시지 추가 + val closeConversationMessage = chatJpaRepository.save( + Chat.createSystemMessage( + chatRoom = chatRoom, + message = "${requester.getProfileOrThrow().codeName}님이 대화를 종료하였습니다.", + chatContentType = ChatContentType.CLOSE_CONVERSATION + ) + ) + + // 2. 최근 채팅 업데이트 + chatRoom.updateRecentChat(closeConversationMessage) + + val unlockInfoOfRequester = codeUnlockService.getUnlockInfo(chatRoom, requester) + val unlockInfoOfPartner = codeUnlockService.getUnlockInfo(chatRoom, partner) + + // 3. 발송자와 수신자의 읽지 않은 메시지 수를 각각 계산 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, requester) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + + val chatResponse = ChatResponse.toResponse(requester, closeConversationMessage) + + // 4. 발송자용 채팅방 응답 (본인 기준 읽지 않은 수) + val requesterChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, requester, closeConversationMessage.getIdOrThrow(), requester, + requesterUnReadCount, + unlockInfoOfRequester + ) + + // 5. 수신자용 채팅방 응답 (상대방 기준 읽지 않은 수 - 새 메시지로 인해 증가) + val partnerChatRoomResponse = ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom, partner, null, requester, + partnerUnReadCount, + unlockInfoOfPartner + ) + + return SavedChatDto(partner, requesterChatRoomResponse, partnerChatRoomResponse, chatResponse) + } + + /** + * 회원 탈퇴 시 모든 채팅방 종료 처리 + * - 차단 처리는 하지 않음 + * - 대화 종료 시스템 메시지만 추가 + * - WebSocket 알림 반환 (MemberService에서 발송) + */ + fun closeAllConversationsForWithdrawal(withdrawnMember: Member): List { + log.info { "회원 탈퇴로 인한 모든 채팅방 종료 시작 - userId: ${withdrawnMember.getIdOrThrow()}" } + + // 1. 탈퇴 회원이 속한 모든 채팅방 조회 (이미 나간 채팅방 제외) + val chatRoomMembers = chatRoomMemberJpaRepository + .findAllByMember(withdrawnMember) + .filter { + !it.hasLeft() && + it.chatRoom.status != ChatRoomStatus.DISABLED + } + + log.info { + "종료할 채팅방 수: ${chatRoomMembers.size}개 - userId: ${withdrawnMember.getIdOrThrow()}" + } + + // 2. 각 채팅방에 대해 대화 종료 처리 + val notifications = chatRoomMembers.map { chatRoomMember -> + val chatRoom = chatRoomMember.chatRoom + val partner = findPartner(chatRoom.getIdOrThrow(), withdrawnMember) + + log.debug { + "채팅방 종료 처리 - chatRoomId: ${chatRoom.getIdOrThrow()}, " + + "partnerId: ${partner.getIdOrThrow()}" + } + + // 3. 채팅방 상태 변경 (차단 없이) + chatRoom.closeConversation() + + // 4. 시스템 메시지 생성 및 WebSocket 응답 반환 + createCloseConversationMessage(chatRoom, withdrawnMember, partner) + } + + log.info { + "회원 탈퇴로 인한 모든 채팅방 종료 완료 - userId: ${withdrawnMember.getIdOrThrow()}, " + + "종료된 채팅방: ${notifications.size}개" + } + + return notifications + } + + /** + * 채팅방 존재 여부 확인 및 조회 + */ + fun findChatRoomBetweenMembers(member1: Member, member2: Member): ChatRoom? { + val chatRoomMembers = chatRoomMemberJpaRepository.findCommonChatRoomMembers( + member1.getIdOrThrow(), + member2.getIdOrThrow() + ) + + return chatRoomMembers.firstOrNull()?.chatRoom + } + + /** + * 메시지 전송 가능 여부 확인 + */ + fun validateCanSendMessage(chatRoomId: Long, sender: Member) { + if (!chatRepository.canSendMessage(chatRoomId, sender)) { + throw ChatException(HttpStatus.FORBIDDEN, "메시지를 전송할 수 없습니다. 채팅방 상태를 확인해주세요.") + } + } + + /** + * 채팅방의 상대방 찾기 (공개 메서드) + */ + fun findPartner(chatRoomId: Long, requester: Member): Member { + return chatRoomRepository.findPartner(chatRoomId, requester) + } + + /** + * 채팅방 ID로 채팅방 조회 (2단계에서 추가) + */ + fun findChatRoomById(chatRoomId: Long): ChatRoom { + return chatRoomRepository.findChatRoomById(chatRoomId) + } + + /** + * ChatResponse 생성 헬퍼 (2단계에서 추가) + */ + fun buildChatResponse(requester: Member, chat: Chat): ChatResponse { + return ChatResponse.toResponse(requester, chat) + } + + /** + * 날짜가 변경되었는지 확인하고 필요시 날짜 메시지 저장 + * + * @param chatRoom 채팅방 + * @param recentChatTimeUtc 최근 채팅 시간 (UTC 기준) + * @param locale 지역 코드 (기본값: "ko") + */ + private fun checkAndSaveDateMessageIfNeeded( + chatRoom: ChatRoom, + recentChatTimeUtc: LocalDate, + locale: String = "ko" + ) { + val todayInLocale = CodelDateTimeFormatter.getToday(locale) + + // UTC 날짜를 지역 시간대로 변환 + val recentChatTimeInLocale = CodelDateTimeFormatter.convertUtcDateToLocale(recentChatTimeUtc, locale) + + // 지역 시간대 기준으로 날짜가 다르면 날짜 메시지 추가 + if (todayInLocale != recentChatTimeInLocale) { + val dateMessage = CodelDateTimeFormatter.formatToLocal(todayInLocale, locale) + chatRepository.saveDateChat(chatRoom, dateMessage) + } + } + + /** + * ChatRoomResponse 생성 헬퍼 (2단계에서 추가) + */ + fun buildChatRoomResponse(chatRoom: ChatRoom, requester: Member, partner: Member): ChatRoomResponse { + val requesterChatRoomMember = + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoom.getIdOrThrow(), requester) + val unReadCount = chatRepository.getUnReadMessageCount(chatRoom, requester) + val unlockInfo = codeUnlockService.getUnlockInfo(chatRoom, requester) + + return ChatRoomResponse.toResponseWithUnlockInfo( + chatRoom = chatRoom, + requester = requester, + lastReadChatId = requesterChatRoomMember?.lastReadChat?.getIdOrThrow(), + partner = partner, + unReadMessageCount = unReadCount, + unlockInfo = unlockInfo + ) + } +} + diff --git a/src/main/kotlin/codel/chat/business/CodeUnlockPolicyService.kt b/src/main/kotlin/codel/chat/business/CodeUnlockPolicyService.kt new file mode 100644 index 00000000..3a1debc8 --- /dev/null +++ b/src/main/kotlin/codel/chat/business/CodeUnlockPolicyService.kt @@ -0,0 +1,60 @@ +package codel.chat.business + +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomStatus +import codel.chat.exception.ChatException +import codel.chat.infrastructure.CodeUnlockRequestJpaRepository +import codel.member.domain.Member +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service + +@Service +class CodeUnlockPolicyService( + private val codeUnlockRequestRepository: CodeUnlockRequestJpaRepository +) { + + /** + * 코드해제 요청 가능 여부 검증 + */ + fun validateCanRequest(chatRoom: ChatRoom, requester: Member, partner: Member) { + // 1. 이미 해제된 경우 + if (chatRoom.isUnlocked) { + throw ChatException(HttpStatus.BAD_REQUEST, "이미 코드가 해제된 채팅방입니다.") + } + + // 2. 진행 중인 요청이 있는 경우 + val prevCodeExchangeRequestByMe = codeUnlockRequestRepository.findPendingRequestByRequester( + chatRoom.getIdOrThrow(), + requester + ) + if (prevCodeExchangeRequestByMe != null) { + throw ChatException(HttpStatus.BAD_REQUEST, "이미 코드해제 요청을 보낸 상태입니다.") + } + + val prevCodeExchangeRequestByPartner = codeUnlockRequestRepository.findPendingRequestByRequester( + chatRoom.getIdOrThrow(), + partner + ) + + if (prevCodeExchangeRequestByPartner != null) { + throw ChatException(HttpStatus.BAD_REQUEST, "상대방으로부터 코드해제 요청을 받은 상태입니다.") + } + + // 3. 채팅방 상태 검증 + if (chatRoom.status == ChatRoomStatus.DISABLED) { + throw ChatException(HttpStatus.BAD_REQUEST, "사용할 수 없는 채팅방입니다.") + } + } + + /** + * 요청 가능 여부 확인 (예외 발생 없음) + */ + fun canRequest(chatRoom: ChatRoom, requester: Member, partner : Member): Boolean { + return try { + validateCanRequest(chatRoom, requester, partner) + true + } catch (e: ChatException) { + false + } + } +} diff --git a/src/main/kotlin/codel/chat/business/CodeUnlockService.kt b/src/main/kotlin/codel/chat/business/CodeUnlockService.kt new file mode 100644 index 00000000..b2dca741 --- /dev/null +++ b/src/main/kotlin/codel/chat/business/CodeUnlockService.kt @@ -0,0 +1,183 @@ +package codel.chat.business + +import codel.chat.domain.* +import codel.chat.exception.UnlockException +import codel.chat.infrastructure.CodeUnlockRequestJpaRepository +import codel.chat.infrastructure.ChatJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.repository.ChatRoomRepository +import codel.config.Loggable +import codel.member.domain.Member +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class CodeUnlockService( + private val codeUnlockRequestRepository: CodeUnlockRequestJpaRepository, + private val chatRoomRepository: ChatRoomRepository, + private val chatJpaRepository: ChatJpaRepository, + private val policyService: CodeUnlockPolicyService, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val asyncNotificationService: IAsyncNotificationService +) : Loggable{ + + /** + * 코드해제 요청 (1단계) + */ + fun requestUnlock(chatRoomId: Long, requester: Member): CodeUnlockRequest { + val chatRoom = chatRoomRepository.findChatRoomById(chatRoomId) + val findPartnerChatRoomMember = + chatRoomMemberJpaRepository.findByChatRoomIdAndMemberNot(chatRoomId, requester) ?: throw UnlockException( + HttpStatus.BAD_REQUEST, "상대방의 정보를 찾을 수 없습니다." + ) + // 정책 검증 + policyService.validateCanRequest(chatRoom, requester, findPartnerChatRoomMember.member) + + // 요청 생성 + val unlockRequest = CodeUnlockRequest( + chatRoom = chatRoom, + requester = requester + ) + + val savedRequest = codeUnlockRequestRepository.save(unlockRequest) + + return savedRequest + } + + /** + * 채팅방의 현재 해제 정보 조회 (1단계) + */ + @Transactional(readOnly = true) + fun getUnlockInfo(chatRoom: ChatRoom, requester: Member): UnlockInfo { + val isUnlocked = chatRoom.isUnlocked + val findPartner = + chatRoomMemberJpaRepository.findByChatRoomIdAndMemberNot(chatRoom.getIdOrThrow(), requester) ?: throw UnlockException( + HttpStatus.BAD_REQUEST, "상대방의 정보를 찾을 수 없습니다." + ) + + val canRequest = policyService.canRequest(chatRoom, requester, findPartner.member) + + val currentRequest = codeUnlockRequestRepository.findLatestPendingByChatRoomId(chatRoom.getIdOrThrow()).firstOrNull() + + return UnlockInfo( + isUnlocked = isUnlocked, + currentRequest = currentRequest, + canRequest = canRequest + ) + } + + /** + * 코드해제 요청 승인 (2단계) + */ + fun approveUnlock(requestId: Long, processor: Member): CodeUnlockRequest { + val unlockRequest = codeUnlockRequestRepository.findById(requestId) + .orElseThrow { UnlockException(HttpStatus.BAD_REQUEST, "존재하지 않는 코드해제 요청입니다.") } + + // 권한 검증 - 요청자가 아닌 다른 채팅방 멤버만 승인 가능 + validateProcessor(unlockRequest, processor) + + // 승인 처리 + unlockRequest.approve(processor) + + // 승인 시스템 메시지 생성 + val systemMessage = chatJpaRepository.save( + Chat.createSystemMessage( + chatRoom = unlockRequest.chatRoom, + message = "코드해제가 승인되었습니다! 이제 서로의 프로필을 확인할 수 있어요.", + chatContentType = ChatContentType.UNLOCKED_APPROVED + ) + ) + + unlockRequest.chatRoom.updateRecentChat(systemMessage) + + // 코드 해제 완료 알림 전송 (양쪽 모두에게) + val requester = unlockRequest.requester + sendCodeUnlockedNotification(processor, requester) + sendCodeUnlockedNotification(requester, processor) + + return unlockRequest + } + + private fun sendCodeUnlockedNotification(receiver: Member, partner: Member) { + receiver.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "코드가 해제되었습니다 👀", + body = "서로의 히든 코드프로필을 확인해보세요!" + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 코드 해제 완료 알림 전송 성공 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}" } + } else { + log.warn { "❌ 코드 해제 완료 알림 전송 실패 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 코드 해제 완료 알림 전송 예외 발생 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 코드 해제 완료 알림을 전송하지 않음 - 수신자: ${receiver.getIdOrThrow()}" } + } + } + + /** + * 코드해제 요청 거절 (2단계) + */ + fun rejectUnlock(requestId: Long, processor: Member): CodeUnlockRequest { + val unlockRequest = codeUnlockRequestRepository.findById(requestId) + .orElseThrow { IllegalArgumentException("존재하지 않는 코드해제 요청입니다.") } + + // 권한 검증 - 요청자가 아닌 다른 채팅방 멤버만 거절 가능 + validateProcessor(unlockRequest, processor) + + // 거절 처리 + unlockRequest.reject(processor) + + // 거절 시스템 메시지 생성 + val systemMessage = chatJpaRepository.save( + Chat.createSystemMessage( + chatRoom = unlockRequest.chatRoom, + message = "사용할 수 없는 채팅방입니다", + chatContentType = ChatContentType.CLOSE_CONVERSATION + ) + ) + + unlockRequest.chatRoom.updateRecentChat(systemMessage) + unlockRequest.chatRoom.reject() + + return unlockRequest + } + + /** + * 처리자 권한 검증 + */ + private fun validateProcessor(unlockRequest: CodeUnlockRequest, processor: Member) { + val chatRoomId = unlockRequest.chatRoom.getIdOrThrow() + + // 1. 요청자의 채팅방이 맞는지 확인 + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoomId, processor) ?: throw UnlockException(HttpStatus.BAD_REQUEST, "요청자의 채팅방을 찾을 수 없습니다.") + // 2. 본인의 요청은 처리할 수 없음 + if (unlockRequest.requester.getIdOrThrow() == processor.getIdOrThrow()) { + throw UnlockException(HttpStatus.BAD_REQUEST, "본인의 요청은 직접 처리할 수 없습니다.") + } + } +} + +/** + * 해제 정보 데이터 클래스 + */ +data class UnlockInfo( + val isUnlocked: Boolean, + val currentRequest: CodeUnlockRequest?, + val canRequest: Boolean +) diff --git a/src/main/kotlin/codel/chat/domain/Chat.kt b/src/main/kotlin/codel/chat/domain/Chat.kt new file mode 100644 index 00000000..eb2c6741 --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/Chat.kt @@ -0,0 +1,87 @@ +package codel.chat.domain + +import codel.chat.exception.ChatException +import codel.chat.presentation.request.ChatSendRequest +import codel.member.domain.Member +import jakarta.persistence.* +import org.springframework.data.annotation.CreatedDate +import org.springframework.http.HttpStatus +import java.time.LocalDateTime + +@Entity +class Chat( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @ManyToOne(optional = false) + @JoinColumn(name = "chat_room_id", nullable = false) + var chatRoom: ChatRoom, + + @ManyToOne + @JoinColumn(name = "from_chat_room_member_id", nullable = true) + var fromChatRoomMember: ChatRoomMember?, + var message: String, + + @Enumerated(EnumType.STRING) + var senderType : ChatSenderType, + + @Enumerated(EnumType.STRING) + var chatContentType : ChatContentType, + + @CreatedDate + var sentAt: LocalDateTime? = null, +) { + companion object { + fun of( + fromChatRoomMember: ChatRoomMember, + chatSendRequest: ChatSendRequest, + ): Chat = + Chat( + id = null, + chatRoom = fromChatRoomMember.chatRoom, + fromChatRoomMember = fromChatRoomMember, + message = chatSendRequest.message, + senderType = chatSendRequest.chatType, + chatContentType = ChatContentType.TEXT, + sentAt = LocalDateTime.now(), + ) + + fun createSystemMessage( + chatRoom : ChatRoom, + message : String, + chatContentType: ChatContentType, + ): Chat = + Chat( + id = null, + chatRoom = chatRoom, + message = message, + senderType = ChatSenderType.SYSTEM, + chatContentType = chatContentType, + fromChatRoomMember = null, + sentAt = LocalDateTime.now(), + ) + } + + fun getIdOrThrow(): Long = id ?: throw ChatException(HttpStatus.BAD_REQUEST, "chatId가 존재하지 않습니다.") + + fun getSentAtOrThrow(): LocalDateTime = + sentAt ?: throw ChatException(HttpStatus.BAD_REQUEST, "채팅 발송 시간이 설정되지 않았습니다.") + + fun getChatType(requester: Member): ChatSenderType { + return when { + senderType == ChatSenderType.SYSTEM -> ChatSenderType.SYSTEM + requester == getFromChatRoomMemberOrThrow().member -> ChatSenderType.MY + else -> ChatSenderType.PARTNER + } + } + + fun getSenderId() : Long?{ + return if (senderType == ChatSenderType.SYSTEM) { + null // 시스템 메시지는 명확히 null + } else { + getFromChatRoomMemberOrThrow().member.getIdOrThrow() + } + } + + fun getFromChatRoomMemberOrThrow(): ChatRoomMember = fromChatRoomMember ?: throw ChatException( + HttpStatus.BAD_REQUEST, "채팅과 관련된 회원을 찾을 수 없습니다.") +} diff --git a/src/main/kotlin/codel/chat/domain/ChatContentType.kt b/src/main/kotlin/codel/chat/domain/ChatContentType.kt new file mode 100644 index 00000000..c1dbd61f --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatContentType.kt @@ -0,0 +1,14 @@ +package codel.chat.domain + +enum class ChatContentType { + TEXT, + MATCHED, + UNLOCKED, + UNLOCKED_REQUEST, + UNLOCKED_APPROVED, // 2단계 추가 + UNLOCKED_REJECTED, // 2단계 추가 + QUESTION, + ONBOARDING, + TIME, + CLOSE_CONVERSATION, +} diff --git a/src/main/kotlin/codel/chat/domain/ChatRoom.kt b/src/main/kotlin/codel/chat/domain/ChatRoom.kt new file mode 100644 index 00000000..90197f99 --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatRoom.kt @@ -0,0 +1,45 @@ +package codel.chat.domain + +import codel.chat.exception.ChatException +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* +import org.springframework.http.HttpStatus +import java.time.LocalDateTime + +@Entity +class ChatRoom( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @OneToOne + @JoinColumn(name = "recent_chat_id") + var recentChat: Chat? = null, + + @Enumerated(EnumType.STRING) + var status: ChatRoomStatus = ChatRoomStatus.LOCKED, + + var isUnlocked: Boolean = false, + + var unlockedAt: LocalDateTime? = null, +) : BaseTimeEntity() { + fun getIdOrThrow(): Long = id ?: throw IllegalStateException("채팅방이 존재하지 않습니다.") + + fun updateRecentChat(recentChat: Chat) { + this.recentChat = recentChat + } + + fun getUnlockedUpdateAtOrThrow() = unlockedAt ?: throw ChatException(HttpStatus.BAD_REQUEST, "코드 해제 요청 또는 승인한 적이 없습니다.") + + fun unlock(){ + isUnlocked = true + status = ChatRoomStatus.UNLOCKED + unlockedAt = LocalDateTime.now() + } + + fun reject(){ + status = ChatRoomStatus.DISABLED + } + + fun closeConversation() { + status = ChatRoomStatus.DISABLED + } +} diff --git a/src/main/kotlin/codel/chat/domain/ChatRoomMember.kt b/src/main/kotlin/codel/chat/domain/ChatRoomMember.kt new file mode 100644 index 00000000..a0e87dae --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatRoomMember.kt @@ -0,0 +1,47 @@ +package codel.chat.domain + +import codel.chat.exception.ChatException +import codel.member.domain.Member +import jakarta.persistence.* +import org.springframework.http.HttpStatus +import java.time.LocalDateTime + +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["chat_room_id", "member_id"])]) +class ChatRoomMember( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @ManyToOne(optional = false) + @JoinColumn(name = "chat_room_id", nullable = false) + var chatRoom: ChatRoom, + @ManyToOne(optional = false) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + @ManyToOne + @JoinColumn(name = "chat_id") + var lastReadChat: Chat? = null, + + // 새로 추가된 필드들 + @Enumerated(EnumType.STRING) + var memberStatus: ChatRoomMemberStatus = ChatRoomMemberStatus.ACTIVE, + + var leftAt: LocalDateTime? = null, +) { + fun leave() { + this.memberStatus = ChatRoomMemberStatus.LEFT + this.leftAt = LocalDateTime.now() + } + + fun block() { + this.memberStatus = ChatRoomMemberStatus.LEFT + this.leftAt = LocalDateTime.now() + } + + fun isActive(): Boolean = memberStatus == ChatRoomMemberStatus.ACTIVE + + fun hasLeft(): Boolean = memberStatus == ChatRoomMemberStatus.LEFT + + fun closeConversation() { + this.chatRoom.closeConversation(); + } +} diff --git a/src/main/kotlin/codel/chat/domain/ChatRoomMemberStatus.kt b/src/main/kotlin/codel/chat/domain/ChatRoomMemberStatus.kt new file mode 100644 index 00000000..fb9e9dbf --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatRoomMemberStatus.kt @@ -0,0 +1,6 @@ +package codel.chat.domain + +enum class ChatRoomMemberStatus(statusName : String) { + ACTIVE("활성 상태"), + LEFT("나간 상태"), +} diff --git a/src/main/kotlin/codel/chat/domain/ChatRoomQuestion.kt b/src/main/kotlin/codel/chat/domain/ChatRoomQuestion.kt new file mode 100644 index 00000000..af82fd0a --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatRoomQuestion.kt @@ -0,0 +1,69 @@ +package codel.chat.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import codel.question.domain.Question +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table( + name = "chat_room_question", + uniqueConstraints = [UniqueConstraint(columnNames = ["chat_room_id", "question_id"])] +) +class ChatRoomQuestion( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + val chatRoom: ChatRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_id", nullable = false) + val question: Question, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "requested_by_member_id", nullable = true) + val requestedBy: Member? = null, + + @Column(nullable = false) + val isUsed: Boolean = false, + + val usedAt: LocalDateTime? = null +) : BaseTimeEntity() { + + fun getIdOrThrow(): Long = id ?: throw IllegalStateException("채팅방 질문이 존재하지 않습니다.") + + fun markAsUsed(requestedBy: Member): ChatRoomQuestion { + return ChatRoomQuestion( + id = this.id, + chatRoom = this.chatRoom, + question = this.question, + requestedBy = requestedBy, + isUsed = true, + usedAt = LocalDateTime.now() + ) + } + + companion object { + fun create(chatRoom: ChatRoom, question: Question, requestedBy: Member): ChatRoomQuestion { + return ChatRoomQuestion( + chatRoom = chatRoom, + question = question, + requestedBy = requestedBy, + isUsed = true, + usedAt = LocalDateTime.now() + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ChatRoomQuestion + return id != null && id == other.id + } + + override fun hashCode(): Int = id?.hashCode() ?: 0 +} diff --git a/src/main/kotlin/codel/chat/domain/ChatRoomStatus.kt b/src/main/kotlin/codel/chat/domain/ChatRoomStatus.kt new file mode 100644 index 00000000..09d58f97 --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatRoomStatus.kt @@ -0,0 +1,7 @@ +package codel.chat.domain + +enum class ChatRoomStatus(statusName: String) { + DISABLED("폐지"), + LOCKED("코드잠김"), + UNLOCKED("코드해제"), +} diff --git a/src/main/kotlin/codel/chat/domain/ChatSenderType.kt b/src/main/kotlin/codel/chat/domain/ChatSenderType.kt new file mode 100644 index 00000000..c8cb528e --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/ChatSenderType.kt @@ -0,0 +1,8 @@ +package codel.chat.domain + +enum class ChatSenderType { + MY, + PARTNER, + USER, + SYSTEM +} diff --git a/src/main/kotlin/codel/chat/domain/CodeUnlockRequest.kt b/src/main/kotlin/codel/chat/domain/CodeUnlockRequest.kt new file mode 100644 index 00000000..c7b31f4e --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/CodeUnlockRequest.kt @@ -0,0 +1,71 @@ +package codel.chat.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table(name = "code_unlock_request") +class CodeUnlockRequest( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + val chatRoom: ChatRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "requester_id", nullable = false) + val requester: Member, + + @Enumerated(EnumType.STRING) + var status: UnlockRequestStatus = UnlockRequestStatus.PENDING, + + val requestedAt: LocalDateTime = LocalDateTime.now(), + + // 2단계에서 사용할 필드들 (미리 준비) + var processedAt: LocalDateTime? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processed_by_id") + var processedBy: Member? = null +) : BaseTimeEntity() { + + fun getIdOrThrow(): Long = id ?: throw IllegalStateException("CodeUnlockRequest ID가 존재하지 않습니다.") + + fun isPending(): Boolean = status == UnlockRequestStatus.PENDING + + /** + * 코드해제 요청 승인 (2단계) + */ + fun approve(processor: Member) { + if (status != UnlockRequestStatus.PENDING) { + throw IllegalStateException("대기 중인 요청만 승인할 수 있습니다.") + } + + status = UnlockRequestStatus.APPROVED + processedAt = LocalDateTime.now() + processedBy = processor + + // ChatRoom의 잠금 해제 + chatRoom.unlock() + } + + /** + * 코드해제 요청 거절 (2단계) + */ + fun reject(processor: Member) { + if (status != UnlockRequestStatus.PENDING) { + throw IllegalStateException("대기 중인 요청만 거절할 수 있습니다.") + } + + status = UnlockRequestStatus.REJECTED + processedAt = LocalDateTime.now() + processedBy = processor + } + + fun isRejected(): Boolean { + return status == UnlockRequestStatus.REJECTED + } +} diff --git a/src/main/kotlin/codel/chat/domain/UnlockRequestStatus.kt b/src/main/kotlin/codel/chat/domain/UnlockRequestStatus.kt new file mode 100644 index 00000000..03dc61fe --- /dev/null +++ b/src/main/kotlin/codel/chat/domain/UnlockRequestStatus.kt @@ -0,0 +1,7 @@ +package codel.chat.domain + +enum class UnlockRequestStatus(val description: String) { + PENDING("대기중"), + APPROVED("승인됨"), // 2단계에서 사용 + REJECTED("거절됨"), // 2단계에서 사용 +} diff --git a/src/main/kotlin/codel/chat/exception/ChatException.kt b/src/main/kotlin/codel/chat/exception/ChatException.kt new file mode 100644 index 00000000..580947b4 --- /dev/null +++ b/src/main/kotlin/codel/chat/exception/ChatException.kt @@ -0,0 +1,9 @@ +package codel.chat.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class ChatException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/chat/exception/UnlockException.kt b/src/main/kotlin/codel/chat/exception/UnlockException.kt new file mode 100644 index 00000000..caa0703f --- /dev/null +++ b/src/main/kotlin/codel/chat/exception/UnlockException.kt @@ -0,0 +1,9 @@ +package codel.chat.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class UnlockException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/chat/infrastructure/ChatJpaRepository.kt b/src/main/kotlin/codel/chat/infrastructure/ChatJpaRepository.kt new file mode 100644 index 00000000..e6558afd --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/ChatJpaRepository.kt @@ -0,0 +1,96 @@ +package codel.chat.infrastructure + +import codel.chat.domain.Chat +import codel.chat.domain.ChatRoom +import codel.member.domain.Member +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.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface ChatJpaRepository : JpaRepository { + @Query( + """ + SELECT c + FROM Chat c + WHERE c.chatRoom = :chatRoom + AND c.senderType != 'SYSTEM' + AND c.fromChatRoomMember IS NOT NULL + """, + ) + fun findAllByFromChatRoom( + chatRoom: ChatRoom, + pageable: Pageable, + ): Page + + @Query( + """ + SELECT count(c) from Chat c + WHERE c.chatRoom = :chatRoom + AND c.sentAt > :afterTime + AND c.fromChatRoomMember.member != :requester + AND ( + c.senderType = 'USER' + OR c.chatContentType IN ('CODE_QUESTION', 'CODE_UNLOCKED_REQUEST') + ) + """, + ) + fun countByChatRoomAfterLastChat( + chatRoom: ChatRoom, + afterTime: LocalDateTime, + requester: Member, + ): Int + + @Query( + """ + SELECT count(c) from Chat c + WHERE c.chatRoom = :chatRoom + AND c.fromChatRoomMember.member != :requester + AND ( + c.senderType = 'USER' + OR c.chatContentType IN ('CODE_QUESTION', 'CODE_UNLOCKED_REQUEST') + ) + """) + fun countByChatRoomAfterLastChat(chatRoom: ChatRoom, requester: Member): Int + + @Query(""" + SELECT c + FROM Chat c + WHERE c.chatRoom = :chatRoom + AND c.id >= :lastChatId + ORDER BY c.id ASC + """) + fun findNextChats( + chatRoom: ChatRoom, + lastChatId: Long, + pageable: Pageable + ): Page + + @Query(""" + SELECT c + FROM Chat c + WHERE c.chatRoom = :chatRoom + ORDER BY c.id ASC + """) + fun findNextChats( + chatRoom: ChatRoom, + pageable: Pageable + ): Page + + @Query(""" + SELECT c + FROM Chat c + WHERE c.chatRoom = :chatRoom + AND c.id < :lastChatId + ORDER BY c.id DESC + """) + fun findPrevChats( + chatRoom: ChatRoom, + lastChatId: Long, + pageable: Pageable + ): Page + +} diff --git a/src/main/kotlin/codel/chat/infrastructure/ChatRoomJpaRepository.kt b/src/main/kotlin/codel/chat/infrastructure/ChatRoomJpaRepository.kt new file mode 100644 index 00000000..ce582a4f --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/ChatRoomJpaRepository.kt @@ -0,0 +1,41 @@ +package codel.chat.infrastructure + +import codel.chat.domain.ChatRoom +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface ChatRoomJpaRepository : JpaRepository { + @Query( + """ + SELECT cr + FROM ChatRoom cr + JOIN ChatRoomMember crm + ON crm.chatRoom.id = cr.id + WHERE crm.member.id = :memberId + """, + ) + fun findMyChatRoomWithPageable( + memberId: Long, + pageable: Pageable, + ): Page + + @Query( + """ + SELECT cr + FROM ChatRoom cr + JOIN ChatRoomMember crm1 ON crm1.chatRoom = cr + JOIN ChatRoomMember crm2 ON crm2.chatRoom = cr + WHERE crm1.member.id = :memberId + AND crm2.member.id = :partnerId + """, + ) + fun findChatRoomByMembers( + @Param("memberId") memberId: Long, + @Param("partnerId") partnerId: Long, + ): ChatRoom? +} diff --git a/src/main/kotlin/codel/chat/infrastructure/ChatRoomMemberJpaRepository.kt b/src/main/kotlin/codel/chat/infrastructure/ChatRoomMemberJpaRepository.kt new file mode 100644 index 00000000..74cba936 --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/ChatRoomMemberJpaRepository.kt @@ -0,0 +1,75 @@ +package codel.chat.infrastructure + +import codel.chat.domain.ChatRoomMember +import codel.chat.domain.ChatRoomMemberStatus +import codel.chat.domain.ChatRoomStatus +import codel.member.domain.Member +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface ChatRoomMemberJpaRepository : JpaRepository { + fun findAllByMember(member: Member): List + + fun findByChatRoomIdAndMemberNot( + chatRoomId: Long, + excludeMember: Member, + ): ChatRoomMember? + + fun findByChatRoomIdAndMember( + chatRoomId: Long, + member: Member, + ): ChatRoomMember? + + @Query( + """ + SELECT crmOther + FROM ChatRoomMember crmMe + JOIN crmMe.chatRoom cr + JOIN ChatRoomMember crmOther ON crmOther.chatRoom = cr + JOIN FETCH crmOther.member m + JOIN FETCH m.profile p + WHERE crmMe.member = :me + AND cr.status = :status + AND crmOther.member != :me + """ + ) + fun findUnlockedOpponentsWithProfile( + @Param("me") me: Member, + @Param("status") status: ChatRoomStatus, + pageable: Pageable + ): Page + + @Query("SELECT crm FROM ChatRoomMember crm WHERE crm.chatRoom.id = :chatRoomId") + fun findByChatRoomId(@Param("chatRoomId") chatRoomId: Long): List + + /** + * 새로 추가된 메서드들 + */ + fun findByMemberAndMemberStatus( + member: Member, + memberStatus: ChatRoomMemberStatus, + pageable: Pageable + ): Page + + /** + * 두 멤버가 함께 속한 채팅방의 ChatRoomMember들을 찾는 메서드 + */ + @Query( + """ + SELECT crm1 + FROM ChatRoomMember crm1 + JOIN ChatRoomMember crm2 ON crm1.chatRoom = crm2.chatRoom + WHERE crm1.member.id = :member1Id + AND crm2.member.id = :member2Id + """ + ) + fun findCommonChatRoomMembers( + @Param("member1Id") member1Id: Long, + @Param("member2Id") member2Id: Long + ): List +} diff --git a/src/main/kotlin/codel/chat/infrastructure/ChatRoomQuestionJpaRepository.kt b/src/main/kotlin/codel/chat/infrastructure/ChatRoomQuestionJpaRepository.kt new file mode 100644 index 00000000..3cb8d7d1 --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/ChatRoomQuestionJpaRepository.kt @@ -0,0 +1,26 @@ +package codel.chat.infrastructure + +import codel.chat.domain.ChatRoomQuestion +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 +interface ChatRoomQuestionJpaRepository : JpaRepository { + + @Query("SELECT crq FROM ChatRoomQuestion crq WHERE crq.chatRoom.id = :chatRoomId") + fun findByChatRoomId(@Param("chatRoomId") chatRoomId: Long): List + + @Query("SELECT crq FROM ChatRoomQuestion crq WHERE crq.chatRoom.id = :chatRoomId AND crq.isUsed = false") + fun findUnusedByChatRoomId(@Param("chatRoomId") chatRoomId: Long): List + + @Query("SELECT crq FROM ChatRoomQuestion crq WHERE crq.chatRoom.id = :chatRoomId AND crq.question.id = :questionId") + fun findByChatRoomIdAndQuestionId( + @Param("chatRoomId") chatRoomId: Long, + @Param("questionId") questionId: Long + ): ChatRoomQuestion? + + @Query("SELECT COUNT(crq) FROM ChatRoomQuestion crq WHERE crq.chatRoom.id = :chatRoomId") + fun countByChatRoomId(@Param("chatRoomId") chatRoomId: Long): Long +} diff --git a/src/main/kotlin/codel/chat/infrastructure/ChatRoomWithMemberInfo.kt b/src/main/kotlin/codel/chat/infrastructure/ChatRoomWithMemberInfo.kt new file mode 100644 index 00000000..0a38f03e --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/ChatRoomWithMemberInfo.kt @@ -0,0 +1,12 @@ +package codel.chat.infrastructure + +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomMember +import codel.member.domain.Member + +data class ChatRoomWithMemberInfo( + val chatRoom: ChatRoom, + val requesterChatRoomMember: ChatRoomMember, + val partner: Member, + val partnerChatRoomMember: ChatRoomMember? +) \ No newline at end of file diff --git a/src/main/kotlin/codel/chat/infrastructure/CodeUnlockRequestJpaRepository.kt b/src/main/kotlin/codel/chat/infrastructure/CodeUnlockRequestJpaRepository.kt new file mode 100644 index 00000000..90f30d34 --- /dev/null +++ b/src/main/kotlin/codel/chat/infrastructure/CodeUnlockRequestJpaRepository.kt @@ -0,0 +1,46 @@ +package codel.chat.infrastructure + +import codel.chat.domain.CodeUnlockRequest +import codel.chat.domain.UnlockRequestStatus +import codel.member.domain.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface CodeUnlockRequestJpaRepository : JpaRepository { + + @Query(""" + SELECT cur FROM CodeUnlockRequest cur + WHERE cur.chatRoom.id = :chatRoomId + AND cur.status = :status + ORDER BY cur.requestedAt DESC + """) + fun findByChatRoomIdAndStatus(chatRoomId: Long, status: UnlockRequestStatus): List + + @Query(""" + SELECT cur FROM CodeUnlockRequest cur + WHERE cur.chatRoom.id = :chatRoomId + AND cur.requester = :requester + AND cur.status = 'PENDING' + """) + fun findPendingRequestByRequester(chatRoomId: Long, requester: Member): CodeUnlockRequest? + + @Query(""" + SELECT cur FROM CodeUnlockRequest cur + WHERE cur.chatRoom.id = :chatRoomId + ORDER BY cur.requestedAt DESC + """) + fun findLatestByChatRoomId(chatRoomId: Long): List + + /** + * ID로 코드해제 요청 조회 (2단계에서 사용) + */ + fun findByIdAndStatus(requestId: Long, status: UnlockRequestStatus): CodeUnlockRequest? + + @Query(""" + SELECT cur FROM CodeUnlockRequest cur + WHERE cur.chatRoom.id = :chatRoomId + AND cur.status = 'PENDING' + ORDER BY cur.requestedAt DESC + """) + fun findLatestPendingByChatRoomId(chatRoomId: Any) : List +} diff --git a/src/main/kotlin/codel/chat/presentation/ChatController.kt b/src/main/kotlin/codel/chat/presentation/ChatController.kt new file mode 100644 index 00000000..f7789514 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/ChatController.kt @@ -0,0 +1,161 @@ +package codel.chat.presentation + +import codel.chat.business.ChatService +import codel.chat.presentation.request.CreateChatRoomRequest +import codel.chat.presentation.request.ChatLogRequest +import codel.chat.presentation.request.ChatSendRequest +import codel.chat.presentation.response.ChatResponse +import codel.chat.presentation.response.ChatRoomEventType +import codel.chat.presentation.response.ChatRoomResponse +import codel.chat.presentation.swagger.ChatControllerSwagger +import codel.config.Loggable +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.* + +@Controller +class ChatController( + private val chatService: ChatService, + private val messagingTemplate: SimpMessagingTemplate, +) : ChatControllerSwagger, Loggable { + @GetMapping("/v1/chatrooms") + override fun getChatRooms( + @LoginMember requester: Member, + @PageableDefault(size = 20000, page = 0) pageable: Pageable, + ): ResponseEntity> { + val chatRoomResponses = chatService.getChatRooms(requester, pageable) + return ResponseEntity.ok(chatRoomResponses) + } + + @GetMapping("/v1/chatroom/{chatRoomId}/chats") + override fun getChats( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestParam(required = false) lastReadChatId: Long?, + @PageableDefault(size = 30, page = 0) pageable: Pageable, + ): ResponseEntity> { + val chatResponses = chatService.getChats(chatRoomId, lastReadChatId, requester, pageable) + return ResponseEntity.ok(chatResponses) + } + + @GetMapping("/v1/chatroom/{chatRoomId}/chats/previous") + override fun getPreviousChats( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestParam(required = false) lastReadChatId: Long?, + @PageableDefault(size = 30, page = 0) pageable: Pageable, + ): ResponseEntity> { + val chatResponses = chatService.getPreviousChats(chatRoomId, lastReadChatId, requester, pageable) + + return ResponseEntity.ok(chatResponses) + } + + @PutMapping("/v1/chatroom/{chatRoomId}/last-chat") + override fun updateLastChat( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestBody chatLogRequest: ChatLogRequest, + ): ResponseEntity { + chatService.updateLastChat(chatRoomId, chatLogRequest.lastChatId, requester) + + return ResponseEntity.noContent().build() + } + + @PostMapping("/v1/chatroom/{chatRoomId}/questions/random") + override fun sendRandomQuestion( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long + ): ResponseEntity { + val result = chatService.sendRandomQuestion(chatRoomId, requester) + + // 1. 채팅방 실시간 메시지 전송 (채팅방에 있는 사용자들에게) + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", result.chatResponse) + + // 2. 발송자에게는 본인용 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.getIdOrThrow()}", + result.requesterChatRoomResponse, + ) + + // 3. 상대방에게는 읽지 않은 수가 증가된 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${result.partner.getIdOrThrow()}", + result.partnerChatRoomResponse, + ) + return ResponseEntity.ok(result.chatResponse) + } + + @PostMapping("/v1/chatroom/{chatRoomId}/chat") + fun sendChat( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestBody chatSendRequest: ChatSendRequest, + ): ResponseEntity { + // 메시지 전송 가능 여부 확인 + chatService.validateCanSendMessage(chatRoomId, requester) + + val responseDto = chatService.saveChat(chatRoomId, requester, chatSendRequest) + + // 상대방에게는 읽지 않은 수가 증가된 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${responseDto.partner.id}", + responseDto.partnerChatRoomResponse, + ) + + // 발송자에게는 본인 기준 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.id}", + responseDto.requesterChatRoomResponse, + ) + + // 채팅방 구독자들에게 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", responseDto.chatResponse) + return ResponseEntity.ok(responseDto.chatResponse) + } + + @PostMapping("/v1/chatroom/{chatRoomId}/leave") + override fun leaveChatRoom( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + ): ResponseEntity { + val requesterChatRoomResponse = chatService.leaveChatRoom(chatRoomId, requester) + + // 본인에게 채팅방 삭제 이벤트 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.id}", + requesterChatRoomResponse.copy(eventType = ChatRoomEventType.REMOVED), + ) + + return ResponseEntity.ok().build() + } + + @PostMapping("/v1/chatroom/{chatRoomId}/close") + override fun closeConversationAtChatRoom( + @LoginMember requester : Member, + @PathVariable chatRoomId: Long, + ) : ResponseEntity { + val responseDto = chatService.closeConversation(chatRoomId, requester) + + // 상대방에게는 읽지 않은 수가 증가된 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${responseDto.partner.id}", + responseDto.partnerChatRoomResponse, + ) + + // 발송자에게는 본인 기준 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.id}", + responseDto.requesterChatRoomResponse, + ) + + // 채팅방 구독자들에게 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", responseDto.chatResponse) + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/codel/chat/presentation/ChatWebSocketController.kt b/src/main/kotlin/codel/chat/presentation/ChatWebSocketController.kt new file mode 100644 index 00000000..a7b30449 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/ChatWebSocketController.kt @@ -0,0 +1,56 @@ +package codel.chat.presentation + +import codel.chat.business.ChatService +import codel.chat.presentation.request.ChatSendRequest +import codel.chat.presentation.request.UpdateLastChatRequest +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import org.springdoc.webmvc.core.service.RequestService +import org.springframework.messaging.handler.annotation.DestinationVariable +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.handler.annotation.Payload +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Controller + +@Controller +class ChatWebSocketController( + private val messagingTemplate: SimpMessagingTemplate, + private val chatService: ChatService, + private val requestService: RequestService, +) { + @MessageMapping("/v1/chatroom/{chatRoomId}/chat") + fun sendChat( + @DestinationVariable("chatRoomId") chatRoomId: Long, + @LoginMember requester: Member, + @Payload chatSendRequest: ChatSendRequest, + ) { + // 메시지 전송 가능 여부 확인 + chatService.validateCanSendMessage(chatRoomId, requester) + + val responseDto = chatService.saveChat(chatRoomId, requester, chatSendRequest) + + // 상대방에게는 읽지 않은 수가 증가된 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${responseDto.partner.id}", + responseDto.partnerChatRoomResponse, + ) + + // 발송자에게는 본인 기준 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.id}", + responseDto.requesterChatRoomResponse, + ) + + // 채팅방 구독자들에게 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", responseDto.chatResponse) + } + + @MessageMapping("/v1/chatroom/{chatRoomId}") + fun readChat( + @DestinationVariable("chatRoomId") chatRoomId: Long, + @LoginMember requester : Member, + @Payload updateLastChatRequest: UpdateLastChatRequest, + ){ + chatService.updateLastChat(chatRoomId, updateLastChatRequest.lastChatId, requester) + } +} diff --git a/src/main/kotlin/codel/chat/presentation/CodeUnlockController.kt b/src/main/kotlin/codel/chat/presentation/CodeUnlockController.kt new file mode 100644 index 00000000..22b445b2 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/CodeUnlockController.kt @@ -0,0 +1,132 @@ +package codel.chat.presentation + +import codel.chat.business.ChatService +import codel.chat.business.CodeUnlockService +import codel.chat.presentation.response.UnlockRequestResponse +import codel.chat.presentation.swagger.CodeUnlockControllerSwagger +import codel.config.argumentresolver.LoginMember +import codel.member.business.MemberService +import codel.member.domain.Member +import org.springframework.http.ResponseEntity +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.web.bind.annotation.* + +@RestController +class CodeUnlockController( + private val codeUnlockService: CodeUnlockService, + private val chatService: ChatService, + private val memberService : MemberService, + private val messagingTemplate: SimpMessagingTemplate +) : CodeUnlockControllerSwagger{ + + /** + * 코드해제 요청 (1단계) + */ + @PostMapping("/v1/chatroom/{chatRoomId}/unlock/request") + override fun requestUnlock( + @LoginMember requester: Member, + @PathVariable chatRoomId: Long + ): ResponseEntity { + + // 코드해제 요청 처리 + val unlockRequest = codeUnlockService.requestUnlock(chatRoomId, requester) + + // 채팅방 업데이트 정보 가져오기 (기존 ChatService 활용) + val chatRoomAndChatResponse = chatService.updateUnlockChatRoom(requester, chatRoomId) + + // 실시간 알림 전송 + // 1. 채팅방 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", chatRoomAndChatResponse.chatResponse) + + // 2. 발송자에게는 본인용 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.id}", + chatRoomAndChatResponse.requesterChatRoomResponse, + ) + + // 3. 상대방에게는 읽지 않은 수가 증가된 채팅방 응답 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${chatRoomAndChatResponse.partner.getIdOrThrow()}", + chatRoomAndChatResponse.partnerChatRoomResponse, + ) + + return ResponseEntity.ok(UnlockRequestResponse.from(unlockRequest)) + } + + /** + * 코드해제 요청 승인 (2단계) + */ + @PutMapping("/v1/unlock-request/{requestId}/approve") + override fun approveUnlock( + @LoginMember processor: Member, + @PathVariable requestId: Long + ): ResponseEntity { + + // 코드해제 승인 처리 + val approvedRequest = codeUnlockService.approveUnlock(requestId, processor) + + // 승인된 채팅방 정보 조회 + val chatRoomId = approvedRequest.chatRoom.getIdOrThrow() + val requester = memberService.findMember(approvedRequest.requester.getIdOrThrow()) + + // 실시간 알림 전송 (승인 메시지) + sendUnlockProcessNotification(chatRoomId, processor, requester, "approved") + + return ResponseEntity.ok(UnlockRequestResponse.from(approvedRequest)) + } + + /** + * 코드해제 요청 거절 (2단계) + */ + @PutMapping("/v1/unlock-request/{requestId}/reject") + override fun rejectUnlock( + @LoginMember processor: Member, + @PathVariable requestId: Long + ): ResponseEntity { + + // 코드해제 거절 처리 + val rejectedRequest = codeUnlockService.rejectUnlock(requestId, processor) + + // 거절된 채팅방 정보 조회 + val chatRoomId = rejectedRequest.chatRoom.getIdOrThrow() + val requester = memberService.findMember(rejectedRequest.requester.getIdOrThrow()) + + + // 실시간 알림 전송 (거절 메시지) + sendUnlockProcessNotification(chatRoomId, processor, requester, "rejected") + + return ResponseEntity.ok(UnlockRequestResponse.from(rejectedRequest, requester)) + } + + /** + * 승인/거절 시 실시간 알림 전송 + */ + private fun sendUnlockProcessNotification( + chatRoomId: Long, + processor: Member, + requester: Member, + action: String + ) { + // 최신 채팅방 정보 조회 (승인/거절 후 상태 반영) + val chatRoom = chatService.findChatRoomById(chatRoomId) + val recentChat = chatRoom.recentChat + + if (recentChat != null) { + // 1. 채팅방 실시간 메시지 전송 + messagingTemplate.convertAndSend("/sub/v1/chatroom/$chatRoomId", + chatService.buildChatResponse(processor, recentChat)) + + // 2. 처리자(승인/거절한 사람)에게 채팅방 업데이트 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${processor.getIdOrThrow()}", + chatService.buildChatRoomResponse(chatRoom, processor, requester) + ) + + // 3. 요청자에게 채팅방 업데이트 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${requester.getIdOrThrow()}", + chatService.buildChatRoomResponse(chatRoom, requester, processor) + ) + } + } +} diff --git a/src/main/kotlin/codel/chat/presentation/request/ChatLogRequest.kt b/src/main/kotlin/codel/chat/presentation/request/ChatLogRequest.kt new file mode 100644 index 00000000..170745c6 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/request/ChatLogRequest.kt @@ -0,0 +1,5 @@ +package codel.chat.presentation.request + +data class ChatLogRequest( + val lastChatId: Long, +) diff --git a/src/main/kotlin/codel/chat/presentation/request/ChatSendRequest.kt b/src/main/kotlin/codel/chat/presentation/request/ChatSendRequest.kt new file mode 100644 index 00000000..7f81d250 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/request/ChatSendRequest.kt @@ -0,0 +1,11 @@ +package codel.chat.presentation.request + +import codel.chat.domain.ChatSenderType +import java.time.LocalDate + +data class ChatSendRequest( + val message: String, + val memberId: Long, + val chatType: ChatSenderType, + val recentChatTime: LocalDate, +) diff --git a/src/main/kotlin/codel/chat/presentation/request/CreateChatRoomRequest.kt b/src/main/kotlin/codel/chat/presentation/request/CreateChatRoomRequest.kt new file mode 100644 index 00000000..ffd7003b --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/request/CreateChatRoomRequest.kt @@ -0,0 +1,8 @@ +package codel.chat.presentation.request + +import java.time.LocalDate + +data class CreateChatRoomRequest( + val partnerId: Long, + val recentChatTime : LocalDate, +) diff --git a/src/main/kotlin/codel/chat/presentation/request/UpdateLastChatRequest.kt b/src/main/kotlin/codel/chat/presentation/request/UpdateLastChatRequest.kt new file mode 100644 index 00000000..5fe416fd --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/request/UpdateLastChatRequest.kt @@ -0,0 +1,5 @@ +package codel.chat.presentation.request + +data class UpdateLastChatRequest( + val lastChatId: Long, +) diff --git a/src/main/kotlin/codel/chat/presentation/response/ChatResponse.kt b/src/main/kotlin/codel/chat/presentation/response/ChatResponse.kt new file mode 100644 index 00000000..ba6c5a8e --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/ChatResponse.kt @@ -0,0 +1,33 @@ +package codel.chat.presentation.response + +import codel.chat.domain.Chat +import codel.chat.domain.ChatContentType +import codel.chat.domain.ChatSenderType +import codel.member.domain.Member +import java.time.LocalDateTime + +data class ChatResponse( + val chatId: Long, + val chatRoomId: Long, + val message: String, + val chatType: ChatSenderType, + val senderId: Long?, // nullable로 변경 + val contentType: ChatContentType, + val sentAt: LocalDateTime, +) { + companion object { + fun toResponse( + requester: Member, + chat: Chat, + ): ChatResponse = + ChatResponse( + chatId = chat.getIdOrThrow(), + chatRoomId = chat.chatRoom.getIdOrThrow(), + chatType = chat.getChatType(requester), + message = chat.message, + senderId = chat.getSenderId(), // 안전한 방식으로 처리 + contentType = chat.chatContentType, + sentAt = chat.getSentAtOrThrow(), + ) + } +} diff --git a/src/main/kotlin/codel/chat/presentation/response/ChatRoomEventType.kt b/src/main/kotlin/codel/chat/presentation/response/ChatRoomEventType.kt new file mode 100644 index 00000000..11fa1381 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/ChatRoomEventType.kt @@ -0,0 +1,6 @@ +package codel.chat.presentation.response + +enum class ChatRoomEventType { + UPDATE, // 메시지 송수신, 읽음 처리 등 일반 업데이트 + REMOVED // 사용자가 채팅방을 나감 +} diff --git a/src/main/kotlin/codel/chat/presentation/response/ChatRoomResponse.kt b/src/main/kotlin/codel/chat/presentation/response/ChatRoomResponse.kt new file mode 100644 index 00000000..97d7912d --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/ChatRoomResponse.kt @@ -0,0 +1,117 @@ +package codel.chat.presentation.response + +import codel.chat.business.UnlockInfo +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomStatus +import codel.member.domain.Member +import codel.member.presentation.response.FullProfileResponse +import java.time.LocalDateTime + +data class ChatRoomResponse( + val chatRoomId: Long, + val eventType: ChatRoomEventType = ChatRoomEventType.UPDATE, + val unReadMessageCount: Int, + val partner: FullProfileResponse, + val lastReadChatId: Long?, + val recentChat: ChatResponse?, + val chatRoomStatus: ChatRoomStatus, + val unlockInfo: UnlockInfoResponse, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun toResponse( + chatRoom: ChatRoom, + requester: Member, + lastReadChatId: Long?, + partner: Member, + unReadMessageCount: Int, + ): ChatRoomResponse = + ChatRoomResponse( + chatRoomId = chatRoom.getIdOrThrow(), + partner = FullProfileResponse.createOpen(partner), // 기본적으로는 Open만 + lastReadChatId = lastReadChatId, + recentChat = chatRoom.recentChat?.let { ChatResponse.toResponse(requester, it) }, + unReadMessageCount = unReadMessageCount, + chatRoomStatus = chatRoom.status, + unlockInfo = UnlockInfoResponse(false, null, false), + createdAt = chatRoom.createdAt, + updatedAt = chatRoom.updatedAt, + ) + + /** + * 상대방 상태를 포함한 새로운 응답 생성 메서드 + */ + fun toResponseWithMemberStatus( + chatRoom: ChatRoom, + requester: Member, + lastReadChatId: Long?, + partner: Member, + unReadMessageCount: Int, + ): ChatRoomResponse = + ChatRoomResponse( + chatRoomId = chatRoom.getIdOrThrow(), + partner = FullProfileResponse.createOpen(partner), // 기본적으로는 Open만 + lastReadChatId = lastReadChatId, + recentChat = chatRoom.recentChat?.let { ChatResponse.toResponse(requester, it) }, + unReadMessageCount = unReadMessageCount, + chatRoomStatus = chatRoom.status, + unlockInfo = UnlockInfoResponse(false, null, false), // 임시 - ChatService에서 수정 예정 + createdAt = chatRoom.createdAt, + updatedAt = chatRoom.updatedAt, + ) + + /** + * unlockInfo를 포함한 새로운 응답 생성 메서드 (1단계 전용) + */ + fun toResponseWithUnlockInfo( + chatRoom: ChatRoom, + requester: Member, + lastReadChatId: Long?, + partner: Member, + unReadMessageCount: Int, + unlockInfo: UnlockInfo, + ): ChatRoomResponse = + ChatRoomResponse( + chatRoomId = chatRoom.getIdOrThrow(), + partner = if (unlockInfo.isUnlocked) { + FullProfileResponse.createFull(partner) // 코드 해제된 경우 Full Profile + } else { + FullProfileResponse.createOpen(partner) // 그렇지 않으면 Open만 + }, + lastReadChatId = lastReadChatId, + recentChat = chatRoom.recentChat?.let { ChatResponse.toResponse(requester, it) }, + unReadMessageCount = unReadMessageCount, + chatRoomStatus = chatRoom.status, + unlockInfo = UnlockInfoResponse.from(unlockInfo), + createdAt = chatRoom.createdAt, + updatedAt = chatRoom.updatedAt, + ) + + fun toResponseWithRemove( + chatRoom: ChatRoom, + chatRoomEventType: ChatRoomEventType = ChatRoomEventType.REMOVED, + requester: Member, + lastReadChatId: Long?, + partner: Member, + unReadMessageCount: Int, + unlockInfo: UnlockInfo, + ): ChatRoomResponse = + ChatRoomResponse( + chatRoomId = chatRoom.getIdOrThrow(), + eventType = chatRoomEventType, + partner = if (unlockInfo.isUnlocked) { + FullProfileResponse.createFull(partner) // 코드 해제된 경우 Full Profile + } else { + FullProfileResponse.createOpen(partner) // 그렇지 않으면 Open만 + }, + lastReadChatId = lastReadChatId, + recentChat = chatRoom.recentChat?.let { ChatResponse.toResponse(requester, it) }, + unReadMessageCount = unReadMessageCount, + chatRoomStatus = chatRoom.status, + unlockInfo = UnlockInfoResponse.from(unlockInfo), + createdAt = chatRoom.createdAt, + updatedAt = chatRoom.updatedAt, + ) + } +} diff --git a/src/main/kotlin/codel/chat/presentation/response/InitialChatRoomResult.kt b/src/main/kotlin/codel/chat/presentation/response/InitialChatRoomResult.kt new file mode 100644 index 00000000..de82d93c --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/InitialChatRoomResult.kt @@ -0,0 +1,6 @@ +package codel.chat.presentation.response + +data class InitialChatRoomResult( + val approverChatRoomResponse: ChatRoomResponse, + val senderChatRoomResponse: ChatRoomResponse +) diff --git a/src/main/kotlin/codel/chat/presentation/response/QuestionSendResult.kt b/src/main/kotlin/codel/chat/presentation/response/QuestionSendResult.kt new file mode 100644 index 00000000..90bc6eff --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/QuestionSendResult.kt @@ -0,0 +1,10 @@ +package codel.chat.presentation.response + +import codel.member.domain.Member + +data class QuestionSendResult( + val chatResponse: ChatResponse, + val partner: Member, + val requesterChatRoomResponse: ChatRoomResponse, // 발송자용 채팅방 응답 + val partnerChatRoomResponse: ChatRoomResponse // 수신자용 채팅방 응답 +) \ No newline at end of file diff --git a/src/main/kotlin/codel/chat/presentation/response/SavedChatDto.kt b/src/main/kotlin/codel/chat/presentation/response/SavedChatDto.kt new file mode 100644 index 00000000..e5cbae54 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/SavedChatDto.kt @@ -0,0 +1,10 @@ +package codel.chat.presentation.response + +import codel.member.domain.Member + +data class SavedChatDto( + val partner: Member, + val requesterChatRoomResponse: ChatRoomResponse, + val partnerChatRoomResponse: ChatRoomResponse, + val chatResponse: ChatResponse, +) diff --git a/src/main/kotlin/codel/chat/presentation/response/UnlockInfoResponse.kt b/src/main/kotlin/codel/chat/presentation/response/UnlockInfoResponse.kt new file mode 100644 index 00000000..88c7dde6 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/UnlockInfoResponse.kt @@ -0,0 +1,19 @@ +package codel.chat.presentation.response + +import codel.chat.business.UnlockInfo + +data class UnlockInfoResponse( + val isUnlocked: Boolean, + val currentRequest: UnlockRequestResponse?, + val canRequest: Boolean +) { + companion object { + fun from(unlockInfo: UnlockInfo): UnlockInfoResponse { + return UnlockInfoResponse( + isUnlocked = unlockInfo.isUnlocked, + currentRequest = unlockInfo.currentRequest?.let { UnlockRequestResponse.from(it) }, + canRequest = unlockInfo.canRequest + ) + } + } +} diff --git a/src/main/kotlin/codel/chat/presentation/response/UnlockRequestResponse.kt b/src/main/kotlin/codel/chat/presentation/response/UnlockRequestResponse.kt new file mode 100644 index 00000000..7959bcbf --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/response/UnlockRequestResponse.kt @@ -0,0 +1,39 @@ +package codel.chat.presentation.response + +import codel.chat.domain.CodeUnlockRequest +import codel.chat.domain.UnlockRequestStatus +import codel.member.domain.Member +import java.time.LocalDateTime + +data class UnlockRequestResponse( + val requestId: Long, + val requesterId: Long, + val requesterName: String, + val status: UnlockRequestStatus, + val requestedAt: LocalDateTime, + val processedAt: LocalDateTime? +) { + companion object { + fun from(request: CodeUnlockRequest): UnlockRequestResponse { + return UnlockRequestResponse( + requestId = request.getIdOrThrow(), + requesterId = request.requester.getIdOrThrow(), + requesterName = request.requester.getProfileOrThrow().getCodeNameOrThrow(), + status = request.status, + requestedAt = request.requestedAt, + processedAt = request.processedAt + ) + } + + fun from(request: CodeUnlockRequest, requester : Member): UnlockRequestResponse { + return UnlockRequestResponse( + requestId = request.getIdOrThrow(), + requesterId = requester.getIdOrThrow(), + requesterName = requester.getProfileOrThrow().getCodeNameOrThrow(), + status = request.status, + requestedAt = request.requestedAt, + processedAt = request.processedAt + ) + } + } +} diff --git a/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt b/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt new file mode 100644 index 00000000..e8325840 --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/swagger/ChatControllerSwagger.kt @@ -0,0 +1,185 @@ +package codel.chat.presentation.swagger + +import codel.chat.presentation.request.CreateChatRoomRequest +import codel.chat.presentation.request.ChatLogRequest +import codel.chat.presentation.response.ChatResponse +import codel.chat.presentation.response.ChatRoomResponse +import codel.question.presentation.response.QuestionResponse +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam + +@Tag(name = "Chat", description = "채팅 관련 API") +interface ChatControllerSwagger { + @Operation( + summary = "채팅방 목록 조회", + description = "내가 참여하고 있는 채팅방 목록 조회", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "성공적으로 채팅방 목록 조회"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun getChatRooms( + @Parameter(hidden = true) @LoginMember requester: Member, + @PageableDefault(size = 10, page = 0) pageable: Pageable, + ): ResponseEntity> + + @Operation( + summary = "채팅 목록 조회", + description = """ + 채팅방의 채팅 메시지 목록을 페이징하여 조회합니다. + + **파라미터 설명:** + - chatRoomId: 조회할 채팅방 ID (필수) + - lastChatId: 마지막으로 읽은 채팅 ID (선택, 무한스크롤 구현용) + - page: 페이지 번호 (기본값: 0) + - size: 페이지 크기 (기본값: 30) + + **사용 예시:** + - 최초 로드: GET /v1/chatroom/123/chats?page=0&size=30 + - 이전 메시지 로드: GET /v1/chatroom/123/chats?lastChatId=456&page=0&size=30 + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "성공적으로 채팅 목록 조회"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "403", description = "해당 채팅방에 접근할 권한이 없음"), + ApiResponse(responseCode = "404", description = "채팅방을 찾을 수 없음"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun getChats( + @Parameter(hidden = true) @LoginMember requester: Member, + @Parameter(description = "채팅방 ID", required = true, example = "123") + @PathVariable chatRoomId: Long, + @Parameter(description = "마지막으로 읽은 채팅 ID (무한스크롤용)", required = false, example = "456") + @RequestParam(required = false) lastReadChatId: Long?, + @PageableDefault(size = 30, page = 0) pageable: Pageable, + ): ResponseEntity> + + @Operation( + summary = "채팅방 이전 메시지 조회", + description = """ + 채팅방의 채팅 메시지 목록을 페이징하여 조회합니다. + + **파라미터 설명:** + - chatRoomId: 조회할 채팅방 ID (필수) + - lastChatId: 마지막으로 읽은 채팅 ID (선택, 무한스크롤 구현용) + - page: 페이지 번호 (기본값: 0) + - size: 페이지 크기 (기본값: 30) + + **사용 예시:** + - 최초 로드: GET /v1/chatroom/123/chats/previous?page=0&size=30 + - 이전 메시지 로드: GET /v1/chatroom/123/chats/previous?lastChatId=456&page=0&size=30 + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "성공적으로 채팅 목록 조회"), + ApiResponse(responseCode = "204", description = "이전 채팅 없음"), + ApiResponse(responseCode = "403", description = "해당 채팅방에 접근할 권한이 없음"), + ApiResponse(responseCode = "404", description = "채팅방을 찾을 수 없음"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun getPreviousChats( + @Parameter(hidden = true) @LoginMember requester: Member, + @Parameter(description = "채팅방 ID", required = true, example = "123") + @PathVariable chatRoomId: Long, + @Parameter(description = "마지막으로 읽은 채팅 ID (무한스크롤용)", required = false, example = "456") + @RequestParam(required = false) lastReadChatId: Long?, + @PageableDefault(size = 30, page = 0) pageable: Pageable, + ): ResponseEntity> + + + + @Operation( + summary = "채팅방에서 마지막으로 읽은 채팅 업데이트", + description = "채팅방을 나가면서 이 채팅방에서 내가 읽은 마지막 채팅 정보 저장", + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "성공적으로 마지막 채팅 정보 저장"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun updateLastChat( + @Parameter(hidden = true) @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + @RequestBody chatLogRequest: ChatLogRequest, + ): ResponseEntity + + @Operation( + summary = "랜덤 질문 전송", + description = "채팅방에 시스템이 추천하는 랜덤 질문을 전송합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "성공적으로 랜덤 질문 전송"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ] + ) + fun sendRandomQuestion( + @Parameter(hidden = true) @LoginMember requester: Member, + @PathVariable chatRoomId: Long + ): ResponseEntity + + @Operation( + summary = "채팅방 대화 종료", + description = "지정된 채팅방의 대화를 종료합니다. 요청한 사용자가 해당 채팅방의 참여자여야 합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "대화 종료 성공"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "403", description = "채팅방 접근 권한 없음"), + ApiResponse(responseCode = "404", description = "존재하지 않는 채팅방"), + ApiResponse(responseCode = "500", description = "서버 내부 오류") + ] + ) + fun closeConversationAtChatRoom( + @Parameter(hidden = true) @LoginMember requester: Member, + @Parameter( + description = "종료할 채팅방의 고유 식별자", + required = true, + example = "12345" + ) + @PathVariable chatRoomId: Long, + ): ResponseEntity + + @Operation( + summary = "채팅방 나가기", + description = "지정된 채팅방을 나갑니다. 요청한 사용자가 해당 채팅방의 참여자여야 합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "대화 종료 성공"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "403", description = "채팅방 접근 권한 없음"), + ApiResponse(responseCode = "404", description = "존재하지 않는 채팅방"), + ApiResponse(responseCode = "500", description = "서버 내부 오류") + ] + ) + fun leaveChatRoom( + @Parameter(hidden = true) @LoginMember requester: Member, + @PathVariable chatRoomId: Long, + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/chat/presentation/swagger/CodeUnlockControllerSwagger.kt b/src/main/kotlin/codel/chat/presentation/swagger/CodeUnlockControllerSwagger.kt new file mode 100644 index 00000000..70cce7ac --- /dev/null +++ b/src/main/kotlin/codel/chat/presentation/swagger/CodeUnlockControllerSwagger.kt @@ -0,0 +1,210 @@ +package codel.chat.presentation.swagger + +import codel.chat.presentation.response.UnlockRequestResponse +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable + +@Tag(name = "Code Unlock", description = "코드해제 관련 API") +interface CodeUnlockControllerSwagger { + + @Operation( + summary = "코드해제 요청", + description = """ + 채팅방에서 상대방에게 코드해제를 요청합니다. + + **기능 설명:** + - 상대방의 숨겨진 프로필 정보를 보기 위해 코드해제를 요청 + - 요청 시 채팅방에 시스템 메시지가 전송됨 + - 실시간으로 상대방에게 알림이 전송됨 + + **제약 조건:** + - 이미 코드가 해제된 채팅방에서는 요청 불가 + - 동일한 사용자가 중복 요청 불가 (대기 중인 요청이 있을 때) + - 비활성화된 채팅방에서는 요청 불가 + + **사용 시나리오:** + 1. 사용자가 채팅방에서 코드해제 버튼 클릭 + 2. 이 API 호출로 요청 생성 + 3. 채팅방에 "코드해제 요청이 왔습니다" 시스템 메시지 표시 + 4. 상대방에게 실시간 알림 전송 + 5. 상대방이 승인/거절 선택 (2단계에서 구현 예정) + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "코드해제 요청 성공" + ), + ApiResponse( + responseCode = "400", + description = """ + 요청이 잘못되었습니다. 가능한 오류: + - 이미 코드가 해제된 채팅방 + - 이미 대기 중인 요청이 존재 + - 비활성화된 채팅방 + """ + ), + ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자" + ), + ApiResponse( + responseCode = "403", + description = "해당 채팅방에 접근할 권한이 없음" + ), + ApiResponse( + responseCode = "404", + description = "채팅방을 찾을 수 없음" + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류" + ), + ], + ) + fun requestUnlock( + @Parameter(hidden = true) @LoginMember requester: Member, + @Parameter( + description = "코드해제를 요청할 채팅방 ID", + required = true, + example = "123" + ) + @PathVariable chatRoomId: Long + ): ResponseEntity + + @Operation( + summary = "코드해제 요청 승인", + description = """ + 대기 중인 코드해제 요청을 승인합니다. + + **기능 설명:** + - 상대방의 코드해제 요청을 승인하여 서로의 프로필을 공개 + - 승인 시 채팅방 상태가 UNLOCKED로 변경됨 + - 채팅방에 승인 완료 시스템 메시지가 전송됨 + + **제약 조건:** + - PENDING 상태인 요청만 승인 가능 + - 본인의 요청은 승인할 수 없음 + - 해당 채팅방의 멤버만 승인 가능 + + **처리 결과:** + 1. 코드해제 요청 상태가 APPROVED로 변경 + 2. 채팅방 isUnlocked = true, status = UNLOCKED로 변경 + 3. "코드해제가 승인되었습니다" 시스템 메시지 전송 + 4. 양쪽 사용자에게 실시간 알림 전송 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "코드해제 요청 승인 성공" + ), + ApiResponse( + responseCode = "400", + description = """ + 요청이 잘못되었습니다. 가능한 오류: + - 이미 처리된 요청 (PENDING 상태가 아님) + - 본인의 요청을 승인하려고 시도 + """ + ), + ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자" + ), + ApiResponse( + responseCode = "403", + description = "해당 채팅방에 접근할 권한이 없음" + ), + ApiResponse( + responseCode = "404", + description = "코드해제 요청을 찾을 수 없음" + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류" + ), + ], + ) + fun approveUnlock( + @Parameter(hidden = true) @LoginMember processor: Member, + @Parameter( + description = "승인할 코드해제 요청 ID", + required = true, + example = "456" + ) + @PathVariable requestId: Long + ): ResponseEntity + + @Operation( + summary = "코드해제 요청 거절", + description = """ + 대기 중인 코드해제 요청을 거절합니다. + + **기능 설명:** + - 상대방의 코드해제 요청을 거절 + - 채팅방 상태는 LOCKED 상태 유지 + - 채팅방에 거절 완료 시스템 메시지가 전송됨 + + **제약 조건:** + - PENDING 상태인 요청만 거절 가능 + - 본인의 요청은 거절할 수 없음 + - 해당 채팅방의 멤버만 거절 가능 + + **처리 결과:** + 1. 코드해제 요청 상태가 REJECTED로 변경 + 2. 채팅방 상태는 변경되지 않음 (여전히 LOCKED) + 3. "코드해제 요청이 거절되었습니다" 시스템 메시지 전송 + 4. 양쪽 사용자에게 실시간 알림 전송 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "코드해제 요청 거절 성공" + ), + ApiResponse( + responseCode = "400", + description = """ + 요청이 잘못되었습니다. 가능한 오류: + - 이미 처리된 요청 (PENDING 상태가 아님) + - 본인의 요청을 거절하려고 시도 + """ + ), + ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자" + ), + ApiResponse( + responseCode = "403", + description = "해당 채팅방에 접근할 권한이 없음" + ), + ApiResponse( + responseCode = "404", + description = "코드해제 요청을 찾을 수 없음" + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류" + ), + ], + ) + fun rejectUnlock( + @Parameter(hidden = true) @LoginMember processor: Member, + @Parameter( + description = "거절할 코드해제 요청 ID", + required = true, + example = "456" + ) + @PathVariable requestId: Long + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/chat/repository/ChatRepository.kt b/src/main/kotlin/codel/chat/repository/ChatRepository.kt new file mode 100644 index 00000000..f6a1f147 --- /dev/null +++ b/src/main/kotlin/codel/chat/repository/ChatRepository.kt @@ -0,0 +1,137 @@ +package codel.chat.repository + +import codel.chat.domain.Chat +import codel.chat.domain.ChatContentType +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomMember +import codel.chat.exception.ChatException +import codel.chat.infrastructure.ChatJpaRepository +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.presentation.request.ChatSendRequest +import codel.member.domain.Member +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.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +@Component +class ChatRepository( + private val chatJpaRepository: ChatJpaRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val chatRoomJpaRepository: ChatRoomJpaRepository, +) { + fun saveChat( + chatRoomId: Long, + requester: Member, + chatSendRequest: ChatSendRequest, + ): Chat { + val requesterChatRoomMember = findMe(chatRoomId, requester) + + return chatJpaRepository.save(Chat.of(requesterChatRoomMember, chatSendRequest)) + } + + fun saveDateChat(chatRoom: ChatRoom, dateMessage: String) { + chatJpaRepository.save(Chat.createSystemMessage(chatRoom = chatRoom, message = dateMessage, chatContentType = ChatContentType.TIME)) + } + + fun findNextChats( + chatRoomId: Long, + lastChatId: Long?, + pageable: Pageable, + ): Page { + val pageableWithSort: Pageable = PageRequest.of(pageable.pageNumber, pageable.pageSize, getChatDefaultSort()) + val chatRoom = + chatRoomJpaRepository.findByIdOrNull(chatRoomId) ?: throw ChatException( + HttpStatus.BAD_REQUEST, + "채팅방을 찾을 수 없습니다.", + ) + + if(lastChatId == null){ + return chatJpaRepository.findNextChats(chatRoom, pageableWithSort) + } + + return chatJpaRepository.findNextChats(chatRoom, lastChatId, pageableWithSort) + } + + fun findPrevChats( + chatRoomId: Long, + lastChatId: Long?, + pageable: Pageable, + ): Page { + val pageableWithSort: Pageable = PageRequest.of(pageable.pageNumber, pageable.pageSize, getChatDefaultSort()) + val chatRoom = + chatRoomJpaRepository.findByIdOrNull(chatRoomId) ?: throw ChatException( + HttpStatus.BAD_REQUEST, + "채팅방을 찾을 수 없습니다.", + ) + + if(lastChatId == null){ + return Page.empty(pageableWithSort) + } + + return chatJpaRepository.findPrevChats(chatRoom, lastChatId, pageableWithSort) + } + + fun findChat(chatId: Long): Chat = + chatJpaRepository.findByIdOrNull(chatId) ?: throw ChatException( + HttpStatus.BAD_REQUEST, + "해당 chatId에 맞는 채팅을 찾을 수 없습니다.", + ) + + fun upsertLastChat( + chatRoomId: Long, + requester: Member, + chat: Chat, + ) { + val requesterChatRoomMember = findMe(chatRoomId, requester) + requesterChatRoomMember.lastReadChat = chat + chatRoomMemberJpaRepository.save(requesterChatRoomMember) + } + + fun getUnReadMessageCount( + chatRoom: ChatRoom, + requester: Member, + ): Int { + val requesterChatRoomMember = + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoom.getIdOrThrow(), requester) + ?: throw ChatException(HttpStatus.BAD_REQUEST, "해당 채팅방에 속해있는 사용자가 아닙니다.") + + if(requesterChatRoomMember.lastReadChat == null){ + return chatJpaRepository.countByChatRoomAfterLastChat(chatRoom, requester) + } + + return chatJpaRepository.countByChatRoomAfterLastChat( + chatRoom, + requesterChatRoomMember.lastReadChat!!.getSentAtOrThrow(), + requester, + ) + } + + /** + * 채팅방에서 메시지 전송 가능 여부 확인 + */ + fun canSendMessage(chatRoomId: Long, sender: Member): Boolean { + val chatRoom = chatRoomJpaRepository.findByIdOrNull(chatRoomId) ?: return false + val allMembers = chatRoomMemberJpaRepository.findByChatRoomId(chatRoomId) + + // 발송자가 활성 상태가 아니면 전송 불가 + val senderMember = allMembers.find { it.member == sender } + if (senderMember?.isActive() != true) return false + + // 상대방이 존재하고 활성 상태인지 확인 + val partner = allMembers.find { it.member != sender } + return partner?.isActive() == true + } + + private fun findMe( + chatRoomId: Long, + requester: Member, + ): ChatRoomMember = + chatRoomMemberJpaRepository.findByChatRoomIdAndMember(chatRoomId, requester) + ?: throw ChatException(HttpStatus.BAD_REQUEST, "해당 채팅방 멤버가 존재하지 않습니다.") + private fun getChatDefaultSort(): Sort = Sort.by(Sort.Order.desc("sentAt")) +} diff --git a/src/main/kotlin/codel/chat/repository/ChatRoomRepository.kt b/src/main/kotlin/codel/chat/repository/ChatRoomRepository.kt new file mode 100644 index 00000000..b5762aa9 --- /dev/null +++ b/src/main/kotlin/codel/chat/repository/ChatRoomRepository.kt @@ -0,0 +1,83 @@ +package codel.chat.repository + +import codel.chat.domain.ChatRoom +import codel.chat.domain.ChatRoomMemberStatus +import codel.chat.exception.ChatException +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.infrastructure.ChatRoomWithMemberInfo +import codel.member.domain.Member +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.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +@Component +class ChatRoomRepository( + private val chatRoomJpaRepository: ChatRoomJpaRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, +) { + fun findChatRooms( + member: Member, + pageable: Pageable, + ): Page { + val pageableWithSort = PageRequest.of(pageable.pageNumber, pageable.pageSize, getChatRoomDefaultSort()) + + return chatRoomJpaRepository.findMyChatRoomWithPageable(member.getIdOrThrow(), pageableWithSort) + } + + /** + * 활성 상태인 채팅방만 조회 (새로운 메서드) + */ + fun findActiveChatRoomsByMember( + member: Member, + pageable: Pageable, + ): Page { + val pageable = PageRequest.of(pageable.pageNumber, pageable.pageSize) + + // 사용자가 ACTIVE 상태인 채팅방들을 조회 + val activeChatRoomMembers = chatRoomMemberJpaRepository.findByMemberAndMemberStatus( + member, + ChatRoomMemberStatus.ACTIVE, + pageable + ) + + return activeChatRoomMembers.map { chatRoomMember -> + val partner = findPartner(chatRoomMember.chatRoom.getIdOrThrow(), member) + val partnerChatRoomMember = chatRoomMemberJpaRepository.findByChatRoomIdAndMember( + chatRoomMember.chatRoom.getIdOrThrow(), + partner + ) + + ChatRoomWithMemberInfo( + chatRoom = chatRoomMember.chatRoom, + requesterChatRoomMember = chatRoomMember, + partner = partner, + partnerChatRoomMember = partnerChatRoomMember + ) + } + } + + fun findPartner( + chatRoomId: Long, + requester: Member, + ): Member { + val chatRoomMember = ( + chatRoomMemberJpaRepository.findByChatRoomIdAndMemberNot(chatRoomId, requester) + ?: throw ChatException(HttpStatus.BAD_REQUEST, "채팅방에 자신을 제외한 다른 사용자가 존재하지 않습니다.") + ) + + return chatRoomMember.member + } + + fun findChatRoomById(chatRoomId: Long): ChatRoom = + chatRoomJpaRepository.findByIdOrNull(chatRoomId) ?: throw ChatException( + HttpStatus.BAD_REQUEST, + "chatRoomId에 해당하는 채팅방을 찾을 수 없습니다.", + ) + + private fun getChatRoomDefaultSort(): Sort = Sort.by(Sort.Order.desc("updatedAt")) +} diff --git a/src/main/kotlin/codel/common/domain/BaseTimeEntity.kt b/src/main/kotlin/codel/common/domain/BaseTimeEntity.kt new file mode 100644 index 00000000..9fb5400b --- /dev/null +++ b/src/main/kotlin/codel/common/domain/BaseTimeEntity.kt @@ -0,0 +1,20 @@ +package codel.common.domain + +import jakarta.persistence.Column +import jakarta.persistence.EntityListeners +import jakarta.persistence.MappedSuperclass +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import java.time.LocalDateTime + +@MappedSuperclass +@EntityListeners(AuditingEntityListener::class) +abstract class BaseTimeEntity { + @CreatedDate + @Column(updatable = false) + lateinit var createdAt: LocalDateTime + + @LastModifiedDate + lateinit var updatedAt: LocalDateTime +} diff --git a/src/main/kotlin/codel/common/util/DateTimeFormatter.kt b/src/main/kotlin/codel/common/util/DateTimeFormatter.kt new file mode 100644 index 00000000..1b920086 --- /dev/null +++ b/src/main/kotlin/codel/common/util/DateTimeFormatter.kt @@ -0,0 +1,107 @@ +package codel.common.util + +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +object DateTimeFormatter { + // 지역별 시간대 매핑 + private val ZONE_MAP = mapOf( + "ko" to ZoneId.of("Asia/Seoul"), + "en" to ZoneId.of("UTC"), + "ja" to ZoneId.of("Asia/Tokyo") + ) + + // 지역별 날짜 포맷터 매핑 + private val DATE_FORMATTER_MAP = mapOf( + "ko" to DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"), + "en" to DateTimeFormatter.ofPattern("MMM dd, yyyy"), + "ja" to DateTimeFormatter.ofPattern("yyyy年MM月dd日") + ) + + /** + * 지역에 맞는 시간대를 반환 (지원하지 않는 지역은 한국 시간대 반환) + * + * @param locale 지역 코드 (예: "ko", "en", "ja") + * @return 해당 지역의 시간대 + */ + private fun getZoneId(locale: String): ZoneId { + return ZONE_MAP[locale] ?: ZONE_MAP["ko"]!! + } + + /** + * 지역에 맞는 날짜 포맷터를 반환 (지원하지 않는 지역은 한국 포맷 반환) + * + * @param locale 지역 코드 (예: "ko", "en", "ja") + * @return 해당 지역의 날짜 포맷터 + */ + private fun getDateFormatter(locale: String): DateTimeFormatter { + return DATE_FORMATTER_MAP[locale] ?: DATE_FORMATTER_MAP["ko"]!! + } + + /** + * 지정된 지역의 시간대 기준으로 오늘 날짜를 해당 지역 포맷으로 반환 + * + * @param locale 지역 코드 (기본값: "ko", 예: "ko" -> "2025년 10월 28일", "en" -> "Oct 28, 2025") + * @return 지역 포맷으로 변환된 오늘 날짜 + */ + fun getTodayInLocalFormat(locale: String = "ko"): String { + val zoneId = getZoneId(locale) + val formatter = getDateFormatter(locale) + val today = LocalDate.now(zoneId) + return today.format(formatter) + } + + /** + * 주어진 LocalDate를 지역 포맷으로 변환 + * + * @param date 변환할 날짜 + * @param locale 지역 코드 (기본값: "ko", 예: "ko", "en", "ja") + * @return 지역 포맷으로 변환된 날짜 + */ + fun formatToLocal(date: LocalDate, locale: String = "ko"): String { + val formatter = getDateFormatter(locale) + return date.format(formatter) + } + + /** + * 지정된 지역의 시간대 기준으로 오늘 날짜를 LocalDate로 반환 + * + * @param locale 지역 코드 (기본값: "ko", 예: "ko", "en", "ja") + * @return 해당 지역 시간대의 오늘 날짜 + */ + fun getToday(locale: String = "ko"): LocalDate { + val zoneId = getZoneId(locale) + return LocalDate.now(zoneId) + } + + /** + * UTC 날짜를 지정된 지역의 시간대 날짜로 변환 + * + * @param utcDate UTC 기준 날짜 + * @param locale 지역 코드 (기본값: "ko", 예: "ko", "en", "ja") + * @return 변환된 날짜 + */ + fun convertUtcDateToLocale(utcDate: LocalDate, locale: String = "ko"): LocalDate { + val targetZoneId = getZoneId(locale) + // UTC 날짜를 자정 시간으로 변환 + val utcDateTime = utcDate.atStartOfDay(ZoneId.of("UTC")) + // 타겟 시간대로 변환 + val targetDateTime = utcDateTime.withZoneSameInstant(targetZoneId) + return targetDateTime.toLocalDate() + } + + /** + * 두 날짜가 지정된 지역 시간대 기준으로 같은 날인지 확인 + * + * @param date1 첫 번째 날짜 (UTC 기준) + * @param date2 두 번째 날짜 (UTC 기준) + * @param locale 지역 코드 (기본값: "ko", 예: "ko", "en", "ja") + * @return 같은 날이면 true, 다르면 false + */ + fun isSameDayInLocale(date1: LocalDate, date2: LocalDate, locale: String = "ko"): Boolean { + val convertedDate1 = convertUtcDateToLocale(date1, locale) + val convertedDate2 = convertUtcDateToLocale(date2, locale) + return convertedDate1 == convertedDate2 + } +} diff --git a/src/main/kotlin/codel/config/AsyncConfig.kt b/src/main/kotlin/codel/config/AsyncConfig.kt new file mode 100644 index 00000000..7f6c0624 --- /dev/null +++ b/src/main/kotlin/codel/config/AsyncConfig.kt @@ -0,0 +1,49 @@ +package codel.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor + +@Configuration +@EnableAsync +class AsyncConfig { + + /** + * 알림 전송용 비동기 Executor + * + * - corePoolSize: 기본 스레드 수 (10개) + * - maxPoolSize: 최대 스레드 수 (50개) + * - queueCapacity: 대기 큐 크기 (100개) + * - threadNamePrefix: 스레드 이름 접두사 (로그 추적용) + */ + @Bean(name = ["notificationExecutor"]) + fun notificationExecutor(): Executor { + val executor = ThreadPoolTaskExecutor() + executor.corePoolSize = 10 + executor.maxPoolSize = 50 + executor.queueCapacity = 100 + executor.setThreadNamePrefix("notification-") + executor.setWaitForTasksToCompleteOnShutdown(true) + executor.setAwaitTerminationSeconds(60) + executor.initialize() + return executor + } + + /** + * 일반적인 비동기 작업용 Executor + */ + @Bean(name = ["taskExecutor"]) + fun taskExecutor(): Executor { + val executor = ThreadPoolTaskExecutor() + executor.corePoolSize = 5 + executor.maxPoolSize = 20 + executor.queueCapacity = 50 + executor.setThreadNamePrefix("async-") + executor.setWaitForTasksToCompleteOnShutdown(true) + executor.setAwaitTerminationSeconds(30) + executor.initialize() + return executor + } +} diff --git a/src/main/kotlin/codel/config/FcmConfig.kt b/src/main/kotlin/codel/config/FcmConfig.kt new file mode 100644 index 00000000..9201e78d --- /dev/null +++ b/src/main/kotlin/codel/config/FcmConfig.kt @@ -0,0 +1,29 @@ +package codel.config + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import jakarta.annotation.PostConstruct +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.core.io.ClassPathResource + +@Configuration +@Profile("!test") +class FcmConfig { + @PostConstruct + fun initialize() { + try { + val options = + FirebaseOptions + .builder() + .setCredentials( + GoogleCredentials.fromStream(ClassPathResource("code-l-b109b-firebase-adminsdk-fbsvc-8c4eb2e6f2.json").inputStream), + ).build() + + FirebaseApp.initializeApp(options) + } catch (e: Exception) { + throw IllegalArgumentException("fcm 연결에 실패하였습니다.") + } + } +} diff --git a/src/main/kotlin/codel/config/HealthController.kt b/src/main/kotlin/codel/config/HealthController.kt new file mode 100644 index 00000000..f55c5dff --- /dev/null +++ b/src/main/kotlin/codel/config/HealthController.kt @@ -0,0 +1,102 @@ +package codel.config + +import org.springframework.beans.factory.annotation.Autowired +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 java.io.File +import javax.sql.DataSource + +@RestController +@RequestMapping("/actuator") +class HealthController { + + @Autowired + private lateinit var dataSource: DataSource + + @GetMapping("/health") + fun health(): ResponseEntity> { + return try { + val dbStatus: Map = checkDatabaseHealth() + val diskStatus: Map = checkDiskSpace() + + val overallStatus = + if (dbStatus["status"] == "UP" && diskStatus["status"] == "UP") "UP" else "DOWN" + + val healthStatus: Map = mapOf( + "status" to overallStatus, + "groups" to listOf("liveness", "readiness"), + "components" to mapOf( + "db" to dbStatus, + "diskSpace" to diskStatus, + "livenessState" to mapOf("status" to "UP"), + "ping" to mapOf("status" to "UP"), + "readinessState" to mapOf("status" to "UP") + ) + ) + + ResponseEntity.ok(healthStatus) + } catch (e: Exception) { + val errorStatus: Map = mapOf( + "status" to "DOWN", + "error" to (e.message ?: "unknown") + ) + ResponseEntity.status(503).body(errorStatus) + } + } + + private fun checkDatabaseHealth(): Map { + return try { + dataSource.connection.use { connection -> + val isValid = connection.isValid(5) // 5초 타임아웃 + if (isValid) { + mapOf( + "status" to "UP", + "details" to mapOf( + "database" to "MySQL", + "validationQuery" to "isValid()" + ) + ) + } else { + mapOf( + "status" to "DOWN", + "error" to "Database connection invalid" + ) + } + } + } catch (e: Exception) { + mapOf( + "status" to "DOWN", + "error" to (e.message ?: "db check failed") + ) + } + } + + private fun checkDiskSpace(): Map { + return try { + val currentDir = File(".") + val totalSpace: Long = currentDir.totalSpace + val freeSpace: Long = currentDir.freeSpace + val threshold: Long = 10 * 1024 * 1024L // 10MB + + val status = if (freeSpace > threshold) "UP" else "DOWN" + + mapOf( + "status" to status, + "details" to mapOf( + "total" to totalSpace, + "free" to freeSpace, + "threshold" to threshold, + "path" to currentDir.absolutePath, + "exists" to currentDir.exists() + ) + ) + } catch (e: Exception) { + mapOf( + "status" to "DOWN", + "error" to (e.message ?: "disk check failed") + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/config/JpaConfig.kt b/src/main/kotlin/codel/config/JpaConfig.kt new file mode 100644 index 00000000..a5cfd507 --- /dev/null +++ b/src/main/kotlin/codel/config/JpaConfig.kt @@ -0,0 +1,8 @@ +package codel.config + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaConfig diff --git a/src/main/kotlin/codel/config/Loggable.kt b/src/main/kotlin/codel/config/Loggable.kt new file mode 100644 index 00000000..c1f9ab3e --- /dev/null +++ b/src/main/kotlin/codel/config/Loggable.kt @@ -0,0 +1,8 @@ +package codel.config + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging + +interface Loggable { + val log: KLogger get() = KotlinLogging.logger {} +} diff --git a/src/main/kotlin/codel/config/RestTemplateConfig.kt b/src/main/kotlin/codel/config/RestTemplateConfig.kt new file mode 100644 index 00000000..a32ea4f6 --- /dev/null +++ b/src/main/kotlin/codel/config/RestTemplateConfig.kt @@ -0,0 +1,11 @@ +package codel.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestTemplate + +@Configuration +class RestTemplateConfig { + @Bean + fun restTemplate(): RestTemplate = RestTemplate() +} diff --git a/src/main/kotlin/codel/config/S3Config.kt b/src/main/kotlin/codel/config/S3Config.kt new file mode 100644 index 00000000..47357136 --- /dev/null +++ b/src/main/kotlin/codel/config/S3Config.kt @@ -0,0 +1,28 @@ +package codel.config + +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 + +@Configuration +class S3Config( + @Value("\${cloud.aws.credentials.access-key}") private val accessKey: String, + @Value("\${cloud.aws.credentials.secret-key}") private val secretKey: String, + @Value("\${cloud.aws.region.static}") private val region: String, +) { + @Bean + fun s3Client(): S3Client { + val credentials = AwsBasicCredentials.create(accessKey, secretKey) + val provider = StaticCredentialsProvider.create(credentials) + + return S3Client + .builder() + .region(Region.of(region)) + .credentialsProvider(provider) + .build() + } +} diff --git a/src/main/kotlin/codel/config/SchedulerConfig.kt b/src/main/kotlin/codel/config/SchedulerConfig.kt new file mode 100644 index 00000000..810b6107 --- /dev/null +++ b/src/main/kotlin/codel/config/SchedulerConfig.kt @@ -0,0 +1,8 @@ +package codel.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +class SchedulerConfig diff --git a/src/main/kotlin/codel/config/SwaggerConfig.kt b/src/main/kotlin/codel/config/SwaggerConfig.kt new file mode 100644 index 00000000..351c24ab --- /dev/null +++ b/src/main/kotlin/codel/config/SwaggerConfig.kt @@ -0,0 +1,45 @@ +package codel.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Contact +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig { + @Bean + fun openAPI(): OpenAPI = + OpenAPI() + .info( + Info() + .title("Code:L API") + .description("API 문서입니다.") + .version("v1.0.0") + .contact( + Contact() + .name("code") + .email("codel@gmail.com"), + ).license( + License() + .name("Apache 2.0") + .url("http://www.apache.org/licenses/LICENSE-2.0"), + ), + ).components( + Components() + .addSecuritySchemes( + "accessToken", + SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.APIKEY) + .`in`(SecurityScheme.In.HEADER) + .bearerFormat("JWT"), + ), + ).addSecurityItem( + SecurityRequirement().addList("accessToken"), + ) +} diff --git a/src/main/kotlin/codel/config/WebMvcConfig.kt b/src/main/kotlin/codel/config/WebMvcConfig.kt new file mode 100644 index 00000000..9f0e4cd0 --- /dev/null +++ b/src/main/kotlin/codel/config/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package codel.config + +import codel.config.argumentresolver.MemberArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig( + private val memberArgumentResolver: MemberArgumentResolver, +) : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(memberArgumentResolver) + } +} diff --git a/src/main/kotlin/codel/config/WebSocketConfig.kt b/src/main/kotlin/codel/config/WebSocketConfig.kt new file mode 100644 index 00000000..9816582d --- /dev/null +++ b/src/main/kotlin/codel/config/WebSocketConfig.kt @@ -0,0 +1,80 @@ +package codel.config + +import codel.config.argumentresolver.WebSocketMemberArgumentResolver +import codel.config.interceptor.ChatRoomSubscriptionInterceptor +import codel.config.interceptor.JwtConnectInterceptor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver +import org.springframework.messaging.simp.config.ChannelRegistration +import org.springframework.messaging.simp.config.MessageBrokerRegistry +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker +import org.springframework.web.socket.config.annotation.StompEndpointRegistry +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfig( + private val chatRoomSubscriptionInterceptor: ChatRoomSubscriptionInterceptor, + private val jwtConnectInterceptor: JwtConnectInterceptor, + private val webSocketMemberArgumentResolver: WebSocketMemberArgumentResolver, +) : WebSocketMessageBrokerConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(webSocketMemberArgumentResolver) + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(chatRoomSubscriptionInterceptor) + registration.interceptors(jwtConnectInterceptor) + registration.taskExecutor(wsInboundExecutor()) + } + + @Bean + fun wsInboundExecutor(): ThreadPoolTaskExecutor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 8 + maxPoolSize = 16 + queueCapacity = 5000 + setThreadNamePrefix("ws-in-") + initialize() + } + + @Bean + fun wsOutboundExecutor(): ThreadPoolTaskExecutor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 8 + maxPoolSize = 16 + queueCapacity = 5000 + setThreadNamePrefix("ws-out-") + initialize() + } + + override fun configureClientOutboundChannel(registration: ChannelRegistration) { + registration.taskExecutor(wsOutboundExecutor()) + } + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry + .addEndpoint("/ws") + .setAllowedOrigins("*") + } + + + override fun configureMessageBroker(registry: MessageBrokerRegistry) { + registry.enableSimpleBroker("/sub") + .setHeartbeatValue(longArrayOf(10000, 10000)) // 10초마다 heartbeat + .setTaskScheduler(heartBeatScheduler()) // 오타 수정: hearBeat → heartBeat + registry.setApplicationDestinationPrefixes("/pub") + } + + @Bean + fun heartBeatScheduler(): ThreadPoolTaskScheduler { + val scheduler = ThreadPoolTaskScheduler() + scheduler.poolSize = 1 + scheduler.setThreadNamePrefix("ws-heartbeat-") + scheduler.initialize() + return scheduler + } +} diff --git a/src/main/kotlin/codel/config/argumentresolver/LoginMember.kt b/src/main/kotlin/codel/config/argumentresolver/LoginMember.kt new file mode 100644 index 00000000..cec43756 --- /dev/null +++ b/src/main/kotlin/codel/config/argumentresolver/LoginMember.kt @@ -0,0 +1,5 @@ +package codel.config.argumentresolver + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class LoginMember diff --git a/src/main/kotlin/codel/config/argumentresolver/MemberArgumentResolver.kt b/src/main/kotlin/codel/config/argumentresolver/MemberArgumentResolver.kt new file mode 100644 index 00000000..a051123f --- /dev/null +++ b/src/main/kotlin/codel/config/argumentresolver/MemberArgumentResolver.kt @@ -0,0 +1,46 @@ +package codel.config.argumentresolver + +import codel.auth.exception.AuthException +import codel.member.business.MemberService +import codel.member.domain.Member +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.MethodParameter +import org.springframework.http.HttpStatus +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 +class MemberArgumentResolver( + private val memberService: MemberService, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(LoginMember::class.java) && + parameter.parameterType == Member::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? { + val httpServletRequest = + webRequest.getNativeRequest(HttpServletRequest::class.java) + ?: throw AuthException(HttpStatus.UNAUTHORIZED, "HttpServletRequest를 가져올 수 없습니다.") + + val memberId = + httpServletRequest.getAttribute("memberId") as? String + ?: throw AuthException(HttpStatus.UNAUTHORIZED, "memberId가 요청이 없습니다.") + + val member = memberService.findMember(memberId.toLong()) + + // 탈퇴한 회원 차단 + if (member.isWithdrawn()) { + throw AuthException(HttpStatus.UNAUTHORIZED, "탈퇴한 회원입니다.") + } + + return member + } +} diff --git a/src/main/kotlin/codel/config/argumentresolver/WebSocketMemberArgumentResolver.kt b/src/main/kotlin/codel/config/argumentresolver/WebSocketMemberArgumentResolver.kt new file mode 100644 index 00000000..36a8e215 --- /dev/null +++ b/src/main/kotlin/codel/config/argumentresolver/WebSocketMemberArgumentResolver.kt @@ -0,0 +1,41 @@ +package codel.config.argumentresolver + +import codel.auth.exception.AuthException +import codel.member.business.MemberService +import codel.member.domain.Member +import org.springframework.core.MethodParameter +import org.springframework.http.HttpStatus +import org.springframework.messaging.Message +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver +import org.springframework.messaging.simp.SimpMessageHeaderAccessor +import org.springframework.messaging.support.MessageHeaderAccessor +import org.springframework.stereotype.Component + +@Component +class WebSocketMemberArgumentResolver( + private val memberService: MemberService, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(LoginMember::class.java) && + parameter.parameterType == Member::class.java + + override fun resolveArgument( + parameter: MethodParameter, + message: Message<*>, + ): Any? { + val headerAccessor = MessageHeaderAccessor.getAccessor(message, SimpMessageHeaderAccessor::class.java) + val sessionAttributes = headerAccessor?.sessionAttributes + val memberId = + sessionAttributes?.get("memberId") as? String + ?: throw AuthException(HttpStatus.UNAUTHORIZED, "memberId가 요청에 없습니다.") + + val member = memberService.findMember(memberId.toLong()) + + // 탈퇴한 회원 차단 + if (member.isWithdrawn()) { + throw AuthException(HttpStatus.UNAUTHORIZED, "탈퇴한 회원입니다.") + } + + return member + } +} diff --git a/src/main/kotlin/codel/config/exception/CodelException.kt b/src/main/kotlin/codel/config/exception/CodelException.kt new file mode 100644 index 00000000..7a6dfde2 --- /dev/null +++ b/src/main/kotlin/codel/config/exception/CodelException.kt @@ -0,0 +1,8 @@ +package codel.config.exception + +import org.springframework.http.HttpStatus + +open class CodelException( + val status: HttpStatus, + override val message: String, +) : RuntimeException(message) diff --git a/src/main/kotlin/codel/config/exception/GlobalExceptionHandler.kt b/src/main/kotlin/codel/config/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..a13af8ff --- /dev/null +++ b/src/main/kotlin/codel/config/exception/GlobalExceptionHandler.kt @@ -0,0 +1,105 @@ +package codel.config.exception + +import codel.config.Loggable +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@RestControllerAdvice +class GlobalExceptionHandler : Loggable { + @ExceptionHandler(CodelException::class) + fun handleCodelException( + e: CodelException, + request: HttpServletRequest, + ): ResponseEntity { + val response = + ErrorResponse( + timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + status = e.status.value(), + path = request.requestURI, + message = e.message, + stackTrace = e.stackTraceToString(), + ) + log.info { + """ + ❗ [CodelException 발생] + - 예외명: ${e::class.simpleName} + - 메시지: ${e.message} + - 상태코드: ${e.status} + - 요청 URI: ${request.requestURI} + - 요청 방식: ${request.method} + - 스택트레이스: + ${e.stackTraceToString()} + """.trimIndent() + } + return ResponseEntity.status(e.status).body(response) + } + + @ExceptionHandler(Exception::class) + fun handleUnexpectedException( + e: Exception, + request: HttpServletRequest, + ): ResponseEntity { + val response = + ErrorResponse( + timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + status = HttpStatus.INTERNAL_SERVER_ERROR.value(), + path = request.requestURI, + message = e.message ?: "예상치 못한 오류가 발생했습니다.", + stackTrace = e.stackTraceToString(), + ) + log.warn { + """ + 💥 [Unhandled Exception] + - 예외명: ${e::class.simpleName} + - 메시지: ${e.message} + - 요청 URI: ${request.requestURI} + - 요청 방식: ${request.method} + - 스택트레이스: + ${e.stackTraceToString()} + """.trimIndent() + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response) + } + + @ExceptionHandler(Error::class) + fun handleFatalError( + e: Error, + request: HttpServletRequest, + ): ResponseEntity { + val response = + ErrorResponse( + timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + status = HttpStatus.INTERNAL_SERVER_ERROR.value(), + path = request.requestURI, + message = e.message ?: "치명적인 오류가 발생했습니다.", + stackTrace = e.stackTraceToString(), + ) + + log.error { + """ + 💩 [Fatal Error 발생] + - 예외명: ${e::class.simpleName} + - 메시지: ${e.message} + - 요청 URI: ${request.requestURI} + - 요청 방식: ${request.method} + - 스택트레이스: + ${e.stackTraceToString()} + """.trimIndent() + } + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response) + } + + data class ErrorResponse( + val timestamp: String, + val status: Int, + val path: String, + val message: String, + val stackTrace: String, + ) +} diff --git a/src/main/kotlin/codel/config/filter/JwtAuthFilter.kt b/src/main/kotlin/codel/config/filter/JwtAuthFilter.kt new file mode 100644 index 00000000..49aa9625 --- /dev/null +++ b/src/main/kotlin/codel/config/filter/JwtAuthFilter.kt @@ -0,0 +1,71 @@ +package codel.config.filter + +import codel.auth.TokenProvider +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(1) +class JwtAuthFilter( + private val tokenProvider: TokenProvider, +) : OncePerRequestFilter() { + companion object { + val EXCLUDE_URIS = + listOf( + "/v1/member/login", + "/v1/admin/login", + "/v1/health", + "/swagger-ui/", + "/v3/api-docs", + "/actuator/", + "/image/", + "/favicon.ico", + "/index.html", + "/ws", + ) + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + if (EXCLUDE_URIS.any { request.requestURI.startsWith(it) }) { + filterChain.doFilter(request, response) + return + } + + val token = resolveToken(request) + + if (token == null || !tokenProvider.validateToken(token)) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + response.writer.write("""{"message": "인증되지 않은 사용자입니다."}""") + return + } + val memberId = tokenProvider.extractMemberId(token) + request.setAttribute("memberId", memberId) + + filterChain.doFilter(request, response) + } + + private fun resolveToken(request: HttpServletRequest): String? { + val bearer = request.getHeader("Authorization") + return if (bearer != null && bearer.startsWith("Bearer ")) { + bearer.substring(7) + } else { + findTokenInCookie(request) + } + } + + private fun findTokenInCookie(request: HttpServletRequest): String? { + val cookies = request.cookies + val accessToken = cookies?.firstOrNull { it.name == "access_token" } + return accessToken?.value + } +} diff --git a/src/main/kotlin/codel/config/filter/MemberLoggingFilter.kt b/src/main/kotlin/codel/config/filter/MemberLoggingFilter.kt new file mode 100644 index 00000000..67ce47fc --- /dev/null +++ b/src/main/kotlin/codel/config/filter/MemberLoggingFilter.kt @@ -0,0 +1,33 @@ +package codel.config.filter + +import codel.config.Loggable +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Order(2) +class MemberLoggingFilter : + OncePerRequestFilter(), + Loggable { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val memberId = request.getAttribute("memberId")?.toString() + if (memberId != null) { + MDC.put("memberId", memberId) + } + try { + log.info { "HTTP ${request.method} ${request.requestURI} memberId=$memberId" } + filterChain.doFilter(request, response) + } finally { + MDC.clear() + } + } +} diff --git a/src/main/kotlin/codel/config/interceptor/ChatRoomSubscriptionInterceptor.kt b/src/main/kotlin/codel/config/interceptor/ChatRoomSubscriptionInterceptor.kt new file mode 100644 index 00000000..1ac3b8fd --- /dev/null +++ b/src/main/kotlin/codel/config/interceptor/ChatRoomSubscriptionInterceptor.kt @@ -0,0 +1,36 @@ +package codel.config.interceptor + +import codel.auth.exception.AuthException +import org.springframework.http.HttpStatus +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.stomp.StompCommand +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor +import org.springframework.stereotype.Component + +@Component +class ChatRoomSubscriptionInterceptor : ChannelInterceptor { + override fun preSend( + message: Message<*>, + channel: MessageChannel, + ): Message<*> { + val accessor = StompHeaderAccessor.wrap(message) + + if (StompCommand.SUBSCRIBE == accessor.command || StompCommand.UNSUBSCRIBE == accessor.command) { + val memberId = findMemberIdFromSession(accessor) + + val originalDestination = accessor.destination + if (originalDestination == "/sub/v1/chatrooms/member") { + val newDestination = "$originalDestination/$memberId" + accessor.destination = newDestination + } + } + + return message + } + + private fun findMemberIdFromSession(accessor: StompHeaderAccessor) = + accessor.sessionAttributes?.get("memberId") + ?: throw AuthException(HttpStatus.BAD_REQUEST, "사용자 아이디가 세션에 존재하지 않습니다.") +} diff --git a/src/main/kotlin/codel/config/interceptor/JwtConnectInterceptor.kt b/src/main/kotlin/codel/config/interceptor/JwtConnectInterceptor.kt new file mode 100644 index 00000000..e08407ac --- /dev/null +++ b/src/main/kotlin/codel/config/interceptor/JwtConnectInterceptor.kt @@ -0,0 +1,37 @@ +package codel.config.interceptor + +import codel.auth.TokenProvider +import codel.auth.exception.AuthException +import org.springframework.http.HttpStatus +import org.springframework.messaging.Message +import org.springframework.messaging.MessageChannel +import org.springframework.messaging.simp.stomp.StompCommand +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.messaging.support.ChannelInterceptor +import org.springframework.stereotype.Component + +@Component +class JwtConnectInterceptor( + private val tokenProvider: TokenProvider, +) : ChannelInterceptor { + override fun preSend( + message: Message<*>, + channel: MessageChannel, + ): Message<*>? { + val accessor = StompHeaderAccessor.wrap(message) + + if (accessor.command == StompCommand.CONNECT) { + val token = resolveToken(accessor) + val memberId = tokenProvider.extractMemberId(token) + accessor.sessionAttributes?.put("memberId", memberId) + } + + return message + } + + private fun resolveToken(accessor: StompHeaderAccessor) = + accessor + .getFirstNativeHeader("Authorization") + ?.removePrefix("Bearer ") + ?: throw AuthException(HttpStatus.BAD_REQUEST, "토큰이 존재하지 않습니다.") +} diff --git a/src/main/kotlin/codel/member/business/MemberService.kt b/src/main/kotlin/codel/member/business/MemberService.kt new file mode 100644 index 00000000..827124c7 --- /dev/null +++ b/src/main/kotlin/codel/member/business/MemberService.kt @@ -0,0 +1,995 @@ +package codel.member.business + +import codel.admin.presentation.request.ImageRejection +import codel.block.infrastructure.BlockMemberRelationJpaRepository +import codel.chat.domain.ChatRoomStatus +import codel.chat.domain.ChatContentType +import codel.chat.exception.ChatException +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.infrastructure.ChatJpaRepository +import codel.chat.presentation.response.SavedChatDto +import codel.chat.repository.ChatRepository +import codel.member.domain.* +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.member.infrastructure.ProfileJpaRepository +import codel.member.infrastructure.FaceImageRepository +import codel.member.infrastructure.CodeImageRepository +import codel.member.infrastructure.RejectionHistoryRepository +import codel.member.presentation.response.FullProfileResponse +import codel.member.presentation.response.MemberProfileDetailResponse +import codel.member.presentation.response.UpdateCodeImagesResponse +import codel.member.presentation.response.UpdateRepresentativeQuestionResponse +import codel.notification.business.NotificationService +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus +import codel.signal.domain.SignalStatus.* +import codel.signal.infrastructure.SignalJpaRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Transactional +@Service +class MemberService( + private val memberRepository: MemberRepository, + private val imageUploader: ImageUploader, + private val memberJpaRepository: MemberJpaRepository, + private val profileJpaRepository: ProfileJpaRepository, + private val signalJpaRepository: SignalJpaRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val chatRoomJpaRepository: ChatRoomJpaRepository, + private val chatJpaRepository: ChatJpaRepository, + private val chatRepository: ChatRepository, + private val notificationService: NotificationService, + private val asyncNotificationService: IAsyncNotificationService, + private val blockMemberRelationJpaRepository: BlockMemberRelationJpaRepository, + private val faceImageRepository: FaceImageRepository, + private val codeImageRepository: CodeImageRepository, + private val rejectionHistoryRepository: RejectionHistoryRepository, + private val questionJpaRepository: codel.question.infrastructure.QuestionJpaRepository, +) { + fun loginMember(member: Member): Member { + val loginMember = memberRepository.loginMember(member) + + if (loginMember.isWithdrawn()) { + val withdrawDate = loginMember.getUpdateDate() + val formattedDate = "%04d-%02d-%02d".format( + withdrawDate.year, + withdrawDate.monthValue, + withdrawDate.dayOfMonth + ) + + val errorMessage = "해당 계정은 $formattedDate 에 탈퇴 처리되어 로그인이 불가능합니다." + + throw MemberException(HttpStatus.FORBIDDEN, errorMessage) + } + + return loginMember + } + + @Transactional(readOnly = true) + fun findMember( + oauthType: OauthType, + oauthId: String, + ): Member = memberRepository.findMember(oauthType, oauthId) + + @Transactional(readOnly = true) + fun findMember(memberId: Long): Member = memberRepository.findMember(memberId) + + /** + * 관리자용: 이미지 포함해서 회원 조회 (Fetch Join) + * MultipleBagFetchException 방지를 위해 2단계로 조회 + */ + @Transactional(readOnly = true) + fun findMemberWithImages(memberId: Long): Member { + // 1단계: Member + Profile만 조회 + val member = memberJpaRepository.findMemberWithProfile(memberId) + ?: throw MemberException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다: $memberId") + + // 2단계: 이미지들과 대표질문을 강제로 초기화 (트랜잭션 범위 내에서) + member.profile?.let { profile -> + profile.codeImages.size // Lazy Loading 초기화 + profile.faceImages.size // Lazy Loading 초기화 + profile.representativeQuestion?.let { question -> + question.content // Lazy Loading 초기화 + } + } + + return member + } + + private fun uploadCodeImage(files: List): CodeImageVO = + CodeImageVO(files.map { file -> imageUploader.uploadFile(file) }) + + private fun sendNotification(member: Member) { + asyncNotificationService.sendAsync( + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), // DISCORD는 없어도 됨 + title = "[회원가입 요청]", + body = member.getProfileOrThrow().getCodeNameOrThrow() + "님이 회원가입을 요청했습니다.", + ), + ) + } + + private fun uploadFaceImage(files: List): FaceImageVO = + FaceImageVO(files.map { file -> imageUploader.uploadFile(file) }) + + fun saveFcmToken( + member: Member, + fcmToken: String, + ) { + memberRepository.updateMemberFcmToken(member, fcmToken) + } + + @Transactional(readOnly = true) + fun findPendingMembers(): List = memberRepository.findPendingMembers() + + fun approveMember(memberId: Long): Member { + val member = memberRepository.findMember(memberId) + + member.memberStatus = MemberStatus.DONE + return memberRepository.updateMember(member) + } + + fun rejectMember( + memberId: Long, + reason: String, + ): Member { + val member = memberRepository.findMember(memberId) + + memberRepository.saveRejectReason(member, reason) + member.reject(reason) + + return memberRepository.updateMember(member) + } + + @Transactional(readOnly = true) + fun findRejectReason(member: Member): String = memberRepository.findRejectReason(member) + + @Transactional(readOnly = true) + fun findMyProfile(member: Member): FullProfileResponse { + val memberId = member.getIdOrThrow() + val findMember = memberRepository.findMember(memberId) + return FullProfileResponse.createFull(findMember, isMyProfile = true) + } + + @Transactional(readOnly = true) + fun recommendMembers(member: Member): List { + val now = LocalDateTime.now() + val todayMidnight = now.toLocalDate().atStartOfDay() + val sevenDaysAgo = now.minusDays(7) + val seed = DailySeedProvider.generateDailySeedForMember(member.getIdOrThrow()) + val candidates = memberJpaRepository.findRandomMembersStatusDoneWithProfile(member.getIdOrThrow(), seed) + + if (candidates.isEmpty()) return emptyList() + + // 1. 가장 최근 추천 시간 기준으로 추천 풀 생성 + val lastRecommendationTime = getLastRecommendationTime(now) + val excludeIds = makeExcludesMemberIds(member, candidates, sevenDaysAgo, lastRecommendationTime) + + val recommendationPool = candidates.filter { candidate -> + candidate.id !in excludeIds + }.take(5) + + // 내가 차단한, 나를 차단한 사용자들을 실시간으로 제외 + val currentlyBlockedIds = blockMemberRelationJpaRepository.findBlockMembersBy(member.getIdOrThrow()) + .mapNotNull { it.blockedMember.id } + val currentlyBlockerIds = blockMemberRelationJpaRepository.findBlockerMembersTo(member.getIdOrThrow()) + .mapNotNull { it.blockedMember.id } + + return recommendationPool.filter { candidate -> + candidate.id !in currentlyBlockedIds + currentlyBlockerIds + } + } + + /** + * 가장 최근의 추천 시간을 구합니다. (10시 또는 22시) + */ + private fun getLastRecommendationTime(now: LocalDateTime): LocalDateTime { + val currentHour = now.hour + val today = now.toLocalDate() + + return when { + currentHour >= 22 -> today.atTime(22, 0) // 오늘 22시 + currentHour >= 10 -> today.atTime(10, 0) // 오늘 10시 + else -> today.minusDays(1).atTime(22, 0) // 어제 22시 + } + } + + private fun makeExcludesMemberIds( + member: Member, + candidates: List, + sevenDaysAgo: LocalDateTime, + targetTime: LocalDateTime + ): MutableList { + val excludeFromMemberIdsAtMidnight = + signalJpaRepository + .findExcludedFromMemberIdsAtMidnight( + member, + candidates, + sevenDaysAgo, + targetTime, + ).toMutableList() + + val excludeToMemberIdsAtMidnight = + signalJpaRepository + .findExcludedToMemberIdsAtMidnight( + member, + candidates, + sevenDaysAgo, + targetTime, + ).toMutableList() + + // targetTime 기준으로 차단된 사용자들만 포함 (실시간 차단은 별도 처리) + val blockedMemberIdsByMe = + blockMemberRelationJpaRepository.findBlockedMemberIdByMeBeforeTime(member.getIdOrThrow(), targetTime) + val blockerMemberIdsToMe = + blockMemberRelationJpaRepository.findBlockMembersByOtherBeforeTime(member.getIdOrThrow(), targetTime) + + val allExcludeIds = excludeFromMemberIdsAtMidnight + allExcludeIds += excludeToMemberIdsAtMidnight + + allExcludeIds += blockedMemberIdsByMe + allExcludeIds += blockerMemberIdsToMe + + return allExcludeIds + } + + @Transactional(readOnly = true) + fun getRandomMembers( + member: Member, + page: Int, + size: Int, + ): Page { + val now = LocalDateTime.now() + val todayMidnight = now.toLocalDate().atStartOfDay() + val sevenDaysAgo = now.minusDays(7) + + val seed = DailySeedProvider.generateMemberSeedEveryTenHours(member.getIdOrThrow()) + val candidates = memberJpaRepository.findRandomMembersStatusDone(member.getIdOrThrow(), seed) + + val allExcludeIds = makeExcludesMemberIds(member, candidates, sevenDaysAgo, todayMidnight) + + + val currentlyBlockedIds = blockMemberRelationJpaRepository.findBlockMembersBy(member.getIdOrThrow()) + .mapNotNull { it.blockedMember.id } + + allExcludeIds += currentlyBlockedIds + + val result = candidates.filter { candidate -> + candidate.id !in allExcludeIds + }.take(size) + + val pageable = PageRequest.of(page, size) + + return PageImpl(result, pageable, result.size.toLong()) + } + + fun findMembersWithFilter( + keyword: String?, + status: String?, + pageable: Pageable, + ): Page { + val statusEnum = status?.let { runCatching { MemberStatus.valueOf(it) }.getOrNull() } + return memberJpaRepository.findMembersWithFilter(keyword, statusEnum, pageable) + } + + fun countAllMembers(): Long = memberJpaRepository.count() + + fun countPendingMembers(): Long = memberJpaRepository.countByMemberStatus(MemberStatus.PENDING) + + fun findMemberProfile( + me: Member, + partnerId: Long, + ): MemberProfileDetailResponse { + + if (me.getIdOrThrow() == partnerId) { + return MemberProfileDetailResponse.createMyProfileResponse(memberRepository.findMember(me.getIdOrThrow())) + } + val partner = memberJpaRepository.findByMemberId(partnerId) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "해당 id에 일치하는 멤버가 없습니다.") + + // 1. 차단 상태 확인 (시그널 상태보다 우선) + if (isBlockedRelationship(me, partner)) { + throw MemberException(HttpStatus.FORBIDDEN, "차단된 사용자입니다.") + } + + // 2. 양방향 시그널 상태 확인 - 더 최근 시그널을 우선으로 + val myLatestSignal = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(me, partner) + val partnerLatestSignal = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(partner, me) + + // 3. 시그널 상태에 따른 처리 + return when { + // 양쪽 모두 시그널이 없는 경우 + myLatestSignal == null && partnerLatestSignal == null -> { + MemberProfileDetailResponse.create(partner, SignalStatus.NONE) + } + + // 양쪽 모두 시그널이 있는 경우 - 더 최근 것을 기준으로 판단 + myLatestSignal != null && partnerLatestSignal != null -> { + handleBothSignalsExist(me, partner, myLatestSignal, partnerLatestSignal) + } + + // 내가 보낸 시그널만 있는 경우 + myLatestSignal != null && partnerLatestSignal == null -> { + handleOnlyMySignalExists(me, partner, myLatestSignal) + } + + // 상대가 보낸 시그널만 있는 경우 + myLatestSignal == null && partnerLatestSignal != null -> { + handleOnlyPartnerSignalExists(me, partner, partnerLatestSignal) + } + + else -> { + MemberProfileDetailResponse.create(partner, SignalStatus.NONE) + } + } + } + + /** + * 차단 관계 확인 + */ + private fun isBlockedRelationship(me: Member, partner: Member): Boolean { + val myBlockedMemberIds = blockMemberRelationJpaRepository.findBlockMembersBy(me.getIdOrThrow()) + .mapNotNull { it.blockedMember.id } + val partnerBlockedMemberIds = blockMemberRelationJpaRepository.findBlockMembersBy(partner.getIdOrThrow()) + .mapNotNull { it.blockedMember.id } + + return partner.getIdOrThrow() in myBlockedMemberIds || + me.getIdOrThrow() in partnerBlockedMemberIds + } + + /** + * 내가 보낸 시그널만 있는 경우 + */ + private fun handleOnlyMySignalExists(me: Member, partner: Member, mySignal: Signal): MemberProfileDetailResponse { + return when (mySignal.senderStatus) { + PENDING, PENDING_HIDDEN -> { + // 내 시그널이 대기 중 + MemberProfileDetailResponse.create(partner, mySignal.senderStatus) + } + + REJECTED -> { + // 내가 거절당한 경우 - 7일 쿨다운 확인 + if (isRejectionCooldownExpired(mySignal)) { + MemberProfileDetailResponse.create(partner, NONE) + } else { + MemberProfileDetailResponse.create(partner, REJECTED) + } + } + + APPROVED -> { + // 내 시그널이 승인된 경우 - 채팅방 상태 확인 + handleApprovedSignal(me, partner, mySignal.senderStatus) + } + + NONE -> { + MemberProfileDetailResponse.create(partner, NONE) + } + } + } + + /** + * 상대가 보낸 시그널만 있는 경우 + */ + private fun handleOnlyPartnerSignalExists( + me: Member, + partner: Member, + partnerSignal: Signal + ): MemberProfileDetailResponse { + return when (partnerSignal.senderStatus) { + PENDING, PENDING_HIDDEN -> { + // 상대의 시그널이 대기 중 - 내가 응답해야 함 + MemberProfileDetailResponse.create(partner, PENDING) + } + + REJECTED -> { + // 상대가 나를 거절한 경우 - 7일 쿨다운 확인 + if (isRejectionCooldownExpired(partnerSignal)) { + MemberProfileDetailResponse.create(partner, NONE) + } else { + MemberProfileDetailResponse.create(partner, REJECTED) + } + } + + APPROVED -> { + // 상대가 나를 승인한 경우 - 채팅방 상태 확인 + handleApprovedSignal(me, partner, partnerSignal.senderStatus) + } + + NONE -> { + MemberProfileDetailResponse.create(partner, NONE) + } + } + } + + /** + * 양쪽 모두 시그널이 있는 경우 + */ + private fun handleBothSignalsExist( + me: Member, + partner: Member, + mySignal: Signal, + partnerSignal: Signal + ): MemberProfileDetailResponse { + + // 더 최근 시그널을 기준으로 상태 결정 + val latestSignal = if (mySignal.updatedAt.isAfter(partnerSignal.updatedAt)) { + mySignal + } else { + partnerSignal + } + + val isMySignalLatest = (latestSignal == mySignal) + + return when { + // 둘 다 승인 상태인 경우 + mySignal.senderStatus in listOf(APPROVED) && + partnerSignal.senderStatus in listOf(APPROVED) -> { + handleApprovedSignal(me, partner, APPROVED) + } + + // 최신 시그널이 거절인 경우 + latestSignal.senderStatus == REJECTED -> { + if (isRejectionCooldownExpired(latestSignal)) { + MemberProfileDetailResponse.create(partner, NONE) + } else { + MemberProfileDetailResponse.create(partner, REJECTED) + } + } + + // 최신 시그널이 대기 중인 경우 + latestSignal.senderStatus in listOf(PENDING, PENDING_HIDDEN) -> { + MemberProfileDetailResponse.create(partner, PENDING) + } + + // 한쪽은 승인, 한쪽은 대기/거절인 경우 + else -> { + // 승인된 시그널이 있다면 채팅방 상태 확인 + val approvedSignal = listOf(mySignal, partnerSignal).find { + it.senderStatus in listOf(APPROVED) + } + + if (approvedSignal != null) { + handleApprovedSignal(me, partner, approvedSignal.senderStatus) + } else { + // 둘 다 승인이 아닌 경우 최신 상태 반환 + MemberProfileDetailResponse.create(partner, latestSignal.senderStatus) + } + } + } + } + + /** + * 승인된 시그널 처리 - 채팅방 상태 확인 + */ + private fun handleApprovedSignal( + me: Member, + partner: Member, + signalStatus: SignalStatus + ): MemberProfileDetailResponse { + val chatRoom = chatRoomJpaRepository.findChatRoomByMembers( + me.getIdOrThrow(), + partner.getIdOrThrow() + ) ?: throw ChatException(HttpStatus.BAD_REQUEST, "승인된 관계이지만 채팅방을 찾을 수 없습니다.") + + val isUnlocked = (chatRoom.status == ChatRoomStatus.UNLOCKED) + return MemberProfileDetailResponse.create(partner, signalStatus, isUnlocked) + } + + /** + * 거절 쿨다운 만료 확인 + */ + private fun isRejectionCooldownExpired(signal: Signal): Boolean { + val daysSinceRejection = ChronoUnit.DAYS.between( + signal.updatedAt.toLocalDate(), + LocalDate.now() + ) + return daysSinceRejection > 7 + } + + /** + * 시그널 전송 가능 여부 확인 (별도 메서드로 제공) + * 프론트엔드에서 버튼 활성화 여부 판단에 사용 + */ + fun canSendSignalTo(me: Member, partnerId: Long): Boolean { + return try { + val partner = memberJpaRepository.findByMemberId(partnerId) ?: return false + + // 차단된 관계면 불가능 + if (isBlockedRelationship(me, partner)) return false + + val myLatestSignal = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(me, partner) + val partnerLatestSignal = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(partner, me) + + when { + // 시그널이 아예 없으면 가능 + myLatestSignal == null && partnerLatestSignal == null -> true + + // 내 시그널만 있는 경우 + myLatestSignal != null && partnerLatestSignal == null -> { + when (myLatestSignal.senderStatus) { + REJECTED -> isRejectionCooldownExpired(myLatestSignal) + APPROVED -> false // 이미 승인됨 + else -> false // 대기 중이면 불가능 + } + } + + // 상대 시그널만 있는 경우 - 응답할 수 있음 + myLatestSignal == null && partnerLatestSignal != null -> { + when (partnerLatestSignal.senderStatus) { + PENDING, PENDING_HIDDEN -> true // 응답 가능 + REJECTED -> isRejectionCooldownExpired(partnerLatestSignal) + else -> false + } + } + + // 둘 다 있는 경우 - 복잡한 로직이므로 안전하게 false + else -> false + } + } catch (e: Exception) { + false + } + } + + fun completePhoneVerification(member: Member) { + member.completePhoneVerification() + } + + /** + * 회원 탈퇴 처리 + * 1. Signal 상태를 모두 REJECTED로 변경 + * 2. 관련된 모든 채팅방 종료 처리 + * 3. 회원 상태를 WITHDRAWN으로 변경 + */ + fun withdrawMember(member: Member, reason: String): List { + // 1. Signal 상태 변경 - 이 회원과 관련된 모든 시그널을 REJECTED로 변경 + val rejectedFromSignals = signalJpaRepository.rejectAllSignalsFromMember(member) + val rejectedToSignals = signalJpaRepository.rejectAllSignalsToMember(member) + + // 2. 모든 채팅방 종료 처리 (시스템 메시지 추가 + 상태 변경) + val chatNotifications = chatRoomMemberJpaRepository.findAllByMember(member) + .filter { !it.hasLeft() && it.chatRoom.status != ChatRoomStatus.DISABLED } + .map { chatRoomMember -> + val chatRoom = chatRoomMember.chatRoom + val partner = chatRoomMemberJpaRepository.findByChatRoomIdAndMemberNot( + chatRoom.getIdOrThrow(), + member + )?.member ?: throw ChatException(HttpStatus.BAD_REQUEST, "상대방을 찾을 수 없습니다.") + + // 채팅방 상태 변경 + chatRoom.closeConversation() + + // 시스템 메시지 생성 + val closeMessage = chatJpaRepository.save( + codel.chat.domain.Chat.createSystemMessage( + chatRoom = chatRoom, + message = "${member.getProfileOrThrow().codeName}님이 대화를 종료하였습니다.", + chatContentType = ChatContentType.CLOSE_CONVERSATION + ) + ) + + chatRoom.updateRecentChat(closeMessage) + + // WebSocket 응답 생성 + val requesterUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, member) + val partnerUnReadCount = chatRepository.getUnReadMessageCount(chatRoom, partner) + val chatResponse = codel.chat.presentation.response.ChatResponse.toResponse(member, closeMessage) + + val requesterChatRoomResponse = codel.chat.presentation.response.ChatRoomResponse.toResponse( + chatRoom, member, closeMessage.getIdOrThrow(), member, requesterUnReadCount + ) + + val partnerChatRoomResponse = codel.chat.presentation.response.ChatRoomResponse.toResponse( + chatRoom, partner, null, member, partnerUnReadCount + ) + + SavedChatDto( + partner, + requesterChatRoomResponse, + partnerChatRoomResponse, + chatResponse + ) + } + + // 3. 회원 상태 변경 + member.withdraw(reason) + memberRepository.updateMember(member) + + // 4. WebSocket 알림 데이터 반환 (Controller에서 발송) + return chatNotifications + } + + // ========== 통계 관련 메서드 ========== + + /** + * 일별 가입자 통계 (최근 30일) + */ + fun getDailySignupStats(): List> { + val startDate = LocalDateTime.now().minusDays(30) + val rawData = memberJpaRepository.getDailySignupStats(startDate) + + return rawData.map { row -> + val date = row[0].toString() + val count = (row[1] as Number).toLong() + date to count + } + } + + /** + * 회원 상태별 통계 + */ + fun getMemberStatusStats(): Map { + val rawData = memberJpaRepository.getMemberStatusStats() + + return rawData.associate { row -> + val status = row[0].toString() + val count = (row[1] as Number).toLong() + status to count + } + } + + /** + * 월별 가입자 통계 (최근 12개월) + */ + fun getMonthlySignupStats(): List> { + val startDate = LocalDateTime.now().minusMonths(12) + val rawData = memberJpaRepository.getMonthlySignupStats(startDate) + + return rawData.map { row -> + val year = (row[0] as Number).toInt() + val month = (row[1] as Number).toInt() + val count = (row[2] as Number).toLong() + Triple(year, month, count) + } + } + + /** + * 오늘 가입자 수 + */ + fun getTodaySignupCount(): Long = memberJpaRepository.getTodaySignupCount() + + /** + * 최근 7일 가입자 수 + */ + fun getWeeklySignupCount(): Long { + val startDate = LocalDateTime.now().minusDays(7) + return memberJpaRepository.getRecentSignupCount(startDate) + } + + /** + * 최근 30일 가입자 수 + */ + fun getMonthlySignupCount(): Long { + val startDate = LocalDateTime.now().minusDays(30) + return memberJpaRepository.getRecentSignupCount(startDate) + } + + /** + * 고급 필터링을 지원하는 회원 목록 조회 + */ + fun findMembersWithFilter( + keyword: String?, + status: String?, + startDate: String?, + endDate: String?, + sort: String?, + direction: String?, + pageable: Pageable + ): Page { + val statusEnum = if (!status.isNullOrBlank()) { + try { + MemberStatus.valueOf(status) + } catch (e: IllegalArgumentException) { + null + } + } else { + null + } + + // 정렬 처리를 위한 새로운 Pageable 생성 + val sortedPageable = createSortedPageable(pageable, sort, direction) + + // 새로운 메서드 사용 + return memberJpaRepository.findMembersWithFilterAdvanced(keyword, statusEnum, sortedPageable) + } + + /** + * 정렬 옵션에 따른 Pageable 생성 + */ + private fun createSortedPageable(pageable: Pageable, sort: String?, direction: String?): Pageable { + val sortDirection = if (direction == "asc") { + org.springframework.data.domain.Sort.Direction.ASC + } else { + org.springframework.data.domain.Sort.Direction.DESC + } + + val sortBy = when (sort) { + "id" -> "id" + "email" -> "email" + "codeName" -> "profile.codeName" + "memberStatus" -> "memberStatus" + "createdAt" -> "createdAt" + else -> "createdAt" + } + + return PageRequest.of( + pageable.pageNumber, + pageable.pageSize, + org.springframework.data.domain.Sort.by(sortDirection, sortBy) + ) + } + + /** + * 상태별 회원 수 조회 + */ + fun countMembersByStatus(status: String): Long { + return try { + val statusEnum = MemberStatus.valueOf(status) + memberJpaRepository.countByMemberStatus(statusEnum) + } catch (e: IllegalArgumentException) { + 0L + } + } + + /** + * 이미지별 거절 처리 (신규) + */ + /** + * 이미지별 거절 처리 (신규) + * - 기존 로직 유지 (하위 호환성) + * - RejectionHistory에 이력 저장 추가 + */ + fun rejectMemberWithImages( + memberId: Long, + faceImageRejections: List?, + codeImageRejections: List? + ) : Member{ + val member = findMember(memberId) + val profile = member.getProfileOrThrow() + + // 현재 거절 차수 조회 (최대값 + 1) + val currentRejectionRound = rejectionHistoryRepository.findMaxRejectionRoundByMemberId(memberId) + 1 + val rejectedAt = LocalDateTime.now() + + // 얼굴 이미지 거절 처리 + faceImageRejections?.forEach { rejection -> + val image = faceImageRepository.findById(rejection.imageId) + .orElseThrow { MemberException(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다: ${rejection.imageId}") } + + if (image.profile.id != profile.id) { + throw MemberException(HttpStatus.BAD_REQUEST, "해당 프로필의 이미지가 아닙니다") + } + + // 기존 로직: 이미지에 거절 상태 기록 (하위 호환성) + image.isApproved = false + image.rejectionReason = rejection.reason + + // 신규 로직: 거절 이력 저장 + val rejectionHistory = RejectionHistory( + member = member, + rejectionRound = currentRejectionRound, + imageType = ImageType.FACE_IMAGE, + imageId = image.id, + imageUrl = image.url, + imageOrder = image.orders, + reason = rejection.reason, + rejectedAt = rejectedAt + ) + rejectionHistoryRepository.save(rejectionHistory) + } + + // 코드 이미지 거절 처리 + codeImageRejections?.forEach { rejection -> + val image = codeImageRepository.findById(rejection.imageId) + .orElseThrow { MemberException(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다: ${rejection.imageId}") } + + if (image.profile.id != profile.id) { + throw MemberException(HttpStatus.BAD_REQUEST, "해당 프로필의 이미지가 아닙니다") + } + + // 기존 로직: 이미지에 거절 상태 기록 (하위 호환성) + image.isApproved = false + image.rejectionReason = rejection.reason + + // 신규 로직: 거절 이력 저장 + val rejectionHistory = RejectionHistory( + member = member, + rejectionRound = currentRejectionRound, + imageType = ImageType.CODE_IMAGE, + imageId = image.id, + imageUrl = image.url, + imageOrder = image.orders, + reason = rejection.reason, + rejectedAt = rejectedAt + ) + rejectionHistoryRepository.save(rejectionHistory) + } + + // 회원 상태를 REJECT로 변경 + member.memberStatus = MemberStatus.REJECT + + // Member의 rejectReason도 업데이트 (기존 호환성) + val reasons = mutableListOf() + if (!faceImageRejections.isNullOrEmpty()) { + reasons.add("얼굴 이미지 거절") + } + if (!codeImageRejections.isNullOrEmpty()) { + reasons.add("코드 이미지 거절") + } + member.rejectReason = reasons.joinToString(", ") + + memberJpaRepository.save(member) + + return member + } + + /** + * 코드 이미지만 수정 (사용자용) + * - existingIds를 통해 유지할 이미지 지정 + * - 지정되지 않은 기존 이미지는 삭제하고 새 이미지로 대체 + */ + fun updateCodeImages( + member: Member, + codeImages: List?, + existingIds: List? + ): UpdateCodeImagesResponse { + // 변경 없음 처리 (프론트가 아무것도 안 보냈을 때) + if (codeImages.isNullOrEmpty() && existingIds.isNullOrEmpty()) { + return UpdateCodeImagesResponse( + uploadedCount = 0, + profileStatus = member.memberStatus, + message = "변경된 이미지가 없습니다" + ) + } + + val findMember = memberJpaRepository + .findByMemberIdWithProfileAndCodeImages(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다.") + val profile = findMember.getProfileOrThrow() + + val keepIdSet = (existingIds ?: emptyList()).toSet() + + // 1) 유지할 기존 엔티티 (정확히 keepIds에 속하는 것만) + val kept = profile.codeImages + .sortedBy { it.orders } + .filter { it.id != null && it.id in keepIdSet } + + // 2) 신규 업로드 개수/총 개수 검증 + val newCount = codeImages?.size ?: 0 + val total = kept.size + newCount + if (total !in 1..3) { + throw MemberException( + HttpStatus.BAD_REQUEST, + "코드 이미지는 1~3개여야 합니다. (현재: 유지 ${kept.size}개 + 신규 ${newCount}개 = ${total}개)" + ) + } + + // 3) 유지하지 않을 기존 이미지는 컬렉션에서 제거 (orphanRemoval로 DB 삭제) + profile.codeImages + .filter { it !in kept } + .toList() + .forEach { img -> + profile.codeImages.remove(img) + // S3 삭제는 트랜잭션 커밋 후 비동기로 권장 (이벤트/리스너) + } + + // 4) 신규 업로드 → 엔티티 생성 → 컬렉션에 add + val startOrder = kept.size + val newEntities = codeImages.orEmpty().mapIndexed { idx, file -> + val url = imageUploader.uploadFile(file) + CodeImage( + profile = profile, // add 전에 명시해도 무방 + url = url, + orders = startOrder + idx, + isApproved = true + ) + } + // 컬렉션에 추가하면서 연관 보장 + newEntities.forEach { img -> + profile.codeImages.add(img) + } + + // 5) orders 재정렬(혹시 모를 갭/중복 정리) + profile.codeImages + .sortedBy { it.orders } + .forEachIndexed { index, img -> img.orders = index } + + // 6) Dual Write: 문자열 필드 동기화 + profile.updateCodeImageUrls(profile.codeImages.sortedBy { it.orders }.map { it.url }) + + // 7) 상태 변경이 필요하면 여기서 처리 (예: 재심사 대기) + // findMember.memberStatus = MemberStatus.PENDING + + // @Transactional + 영속 상태 → save 호출 불필요 + return UpdateCodeImagesResponse( + uploadedCount = newCount, + profileStatus = findMember.memberStatus, + message = "코드 이미지 ${total}개로 변경되었습니다 (유지: ${kept.size}개, 신규: ${newCount}개)" + ) + } + + /** + * 대표 질문 및 답변 수정 (사용자용) + */ + fun updateRepresentativeQuestion( + member: Member, + questionId: Long, + answer: String + ): UpdateRepresentativeQuestionResponse { + val profile = member.getProfileOrThrow() + + // Question 조회 + val question = questionJpaRepository.findById(questionId) + .orElseThrow { MemberException(HttpStatus.NOT_FOUND, "질문을 찾을 수 없습니다: $questionId") } + + // 질문이 활성화되어 있는지 확인 + if (!question.isActive) { + throw MemberException(HttpStatus.BAD_REQUEST, "비활성화된 질문은 선택할 수 없습니다") + } + + // Question과 Answer 업데이트 + profile.representativeQuestion = question + profile.representativeAnswer = answer + + profileJpaRepository.save(profile) + + return UpdateRepresentativeQuestionResponse( + representativeQuestion = UpdateRepresentativeQuestionResponse.QuestionInfo( + id = question.getIdOrThrow(), + content = question.content, + category = question.category.name + ), + representativeAnswer = answer, + message = "대표 질문 및 답변이 수정되었습니다." + ) + } + + // ========== 거절 이력 관리 ========== + + /** + * 특정 회원의 모든 거절 이력 조회 (최신순) + */ + @Transactional(readOnly = true) + fun getRejectionHistories(memberId: Long): List { + return rejectionHistoryRepository.findByMemberIdOrderByRejectedAtDesc(memberId) + } + + /** + * 특정 회원의 특정 차수 거절 이력 조회 + */ + @Transactional(readOnly = true) + fun getRejectionHistoriesByRound(memberId: Long, rejectionRound: Int): List { + return rejectionHistoryRepository.findByMemberIdAndRejectionRound(memberId, rejectionRound) + } + + /** + * 특정 회원의 최대 거절 차수 조회 + */ + @Transactional(readOnly = true) + fun getMaxRejectionRound(memberId: Long): Int { + return rejectionHistoryRepository.findMaxRejectionRoundByMemberId(memberId) + } + + /** + * 특정 회원의 거절 이력 개수 조회 + */ + @Transactional(readOnly = true) + fun getRejectionHistoryCount(memberId: Long): Long { + return rejectionHistoryRepository.countByMemberId(memberId) + } +} diff --git a/src/main/kotlin/codel/member/business/ProfileReviewService.kt b/src/main/kotlin/codel/member/business/ProfileReviewService.kt new file mode 100644 index 00000000..fcc2cf66 --- /dev/null +++ b/src/main/kotlin/codel/member/business/ProfileReviewService.kt @@ -0,0 +1,387 @@ +package codel.member.business + +import codel.member.domain.CodeImage +import codel.member.domain.FaceImage +import codel.member.domain.ImageUploader +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.exception.MemberException +import codel.member.infrastructure.CodeImageRepository +import codel.member.infrastructure.FaceImageRepository +import codel.member.infrastructure.MemberJpaRepository +import codel.member.domain.ImageType +import codel.member.infrastructure.RejectionHistoryRepository +import codel.member.presentation.response.ProfileRejectionInfoResponse +import codel.member.presentation.response.RejectedImageDto +import codel.member.presentation.response.ReplaceImagesResponse +import codel.member.presentation.response.ProfileImagesResponse +import codel.member.presentation.response.ProfileImageDto +import codel.member.presentation.response.ResubmitProfileResponse +import codel.verification.domain.VerificationImage +import codel.verification.infrastructure.VerificationImageJpaRepository +import codel.verification.infrastructure.StandardVerificationImageJpaRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException + +/** + * 프로필 심사 관련 서비스 + */ +@Service +@Transactional +class ProfileReviewService( + private val memberJpaRepository: MemberJpaRepository, + private val faceImageRepository: FaceImageRepository, + private val codeImageRepository: CodeImageRepository, + private val imageUploader: ImageUploader, + private val rejectionHistoryRepository: RejectionHistoryRepository, + private val verificationImageRepository: VerificationImageJpaRepository, + private val standardVerificationImageRepository: StandardVerificationImageJpaRepository +) { + + /** + * 프로필 거절 정보 조회 + */ + @Transactional(readOnly = true) + fun getRejectionInfo(member: Member): ProfileRejectionInfoResponse { + val profile = member.getProfileOrThrow() + + // 거절된 얼굴 이미지 조회 + val rejectedFaceImages = faceImageRepository + .findByProfileIdAndIsApprovedFalse(profile.id!!) + .map { image -> + RejectedImageDto( + imageId = image.id, + url = image.url, + order = image.orders, + rejectionReason = image.rejectionReason ?: "사유 없음" + ) + } + + // 거절된 코드 이미지 조회 + val rejectedCodeImages = codeImageRepository + .findByProfileIdAndIsApprovedFalse(profile.id!!) + .map { image -> + RejectedImageDto( + imageId = image.id, + url = image.url, + order = image.orders, + rejectionReason = image.rejectionReason ?: "사유 없음" + ) + } + + return ProfileRejectionInfoResponse( + status = member.memberStatus, + hasFaceImageRejection = rejectedFaceImages.isNotEmpty(), + hasCodeImageRejection = rejectedCodeImages.isNotEmpty(), + rejectedFaceImages = rejectedFaceImages, + rejectedCodeImages = rejectedCodeImages + ) + } + + /** + * 프로필 이미지 전체 조회 (승인된 이미지 + 거절된 이미지) + * - 거절된 이미지가 하나라도 있으면 해당 타입은 빈 리스트 반환 + */ + @Transactional(readOnly = true) + fun getProfileImages(member: Member): ProfileImagesResponse { + val profile = member.getProfileOrThrow() + + // 얼굴 이미지 조회 + val allFaceImages = faceImageRepository.findByProfileIdOrderByOrdersAsc(profile.id!!) + + val faceImages = if (allFaceImages.any { !it.isApproved }) { + // 거절된 이미지가 하나라도 있으면 빈 리스트 반환 + emptyList() + } else { + // 모두 승인된 경우에만 이미지 반환 + allFaceImages.map { image -> + ProfileImageDto( + imageId = image.id, + url = image.url, + order = image.orders, + isApproved = image.isApproved, + rejectionReason = image.rejectionReason + ) + } + } + + // 코드 이미지 조회 + val allCodeImages = codeImageRepository.findByProfileIdOrderByOrdersAsc(profile.id!!) + val codeImages = if (allCodeImages.any { !it.isApproved }) { + // 거절된 이미지가 하나라도 있으면 빈 리스트 반환 + emptyList() + } else { + // 모두 승인된 경우에만 이미지 반환 + allCodeImages.map { image -> + ProfileImageDto( + imageId = image.id, + url = image.url, + order = image.orders, + isApproved = image.isApproved, + rejectionReason = image.rejectionReason + ) + } + } + + return ProfileImagesResponse( + faceImages = faceImages, + codeImages = codeImages + ) + } + + /** + * 거절된 이미지 전체 교체 (얼굴 + 코드 통합) + * - existingIds를 통해 유지할 이미지 지정 + * - 지정되지 않은 기존 이미지는 삭제하고 새 이미지로 대체 + */ + fun replaceImages( + member: Member, + faceImages: List?, + codeImages: List?, + existingFaceImageIds: List?, + existingCodeImageIds: List? + ): ReplaceImagesResponse { + val findMember = memberJpaRepository.findMemberWithProfile(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 조회할 수 없습니다.") + val profile = findMember.getProfileOrThrow() + + var uploadedCount = 0 + val messages = mutableListOf() + var changed = false + + // --- 얼굴 이미지 섹션: 입력이 있을 때만 처리 --- + if (!faceImages.isNullOrEmpty() || !existingFaceImageIds.isNullOrEmpty()) { + val keepIds = (existingFaceImageIds ?: emptyList()).toSet() + + // 유지할 기존(정확히 keepIds에 포함된 것) + val kept = profile.faceImages + .sortedBy { it.orders } + .filter { it.id != null && it.id in keepIds } + + val newCount = faceImages?.size ?: 0 + val total = kept.size + newCount + if (total != 2) { + throw MemberException( + HttpStatus.BAD_REQUEST, + "얼굴 이미지는 총 2개여야 합니다. (현재: 유지 ${kept.size}개 + 신규 ${newCount}개 = ${total}개)" + ) + } + + // 제거(컬렉션에서만 조작 → orphanRemoval로 DB 삭제, 역참조도 해제) + profile.faceImages.filter { it !in kept }.toList().forEach { img -> + profile.faceImages.remove(img) + // S3 삭제는 AfterCommit 이벤트에서 권장 + } + + // 신규 추가 + val added = faceImages.orEmpty().mapIndexed { idx, file -> + val url = imageUploader.uploadFile(file) + FaceImage(profile = profile, url = url, orders = kept.size + idx, isApproved = true) + } + added.forEach { img -> + profile.faceImages.add(img) + } + + // order 재정렬(0..n-1) + profile.faceImages.sortedBy { it.orders }.forEachIndexed { i, img -> img.orders = i } + + // Dual write + profile.updateFaceImageUrls(profile.faceImages.sortedBy { it.orders }.map { it.url }) + + uploadedCount += newCount + messages += "얼굴 이미지 2개 반영(유지 ${kept.size}, 신규 ${newCount})" + changed = true + } + + // --- 코드 이미지 섹션: 입력이 있을 때만 처리 --- + if (!codeImages.isNullOrEmpty() || !existingCodeImageIds.isNullOrEmpty()) { + val keepIds = (existingCodeImageIds ?: emptyList()).toSet() + + val kept = profile.codeImages + .sortedBy { it.orders } + .filter { it.id != null && it.id in keepIds } + + val newCount = codeImages?.size ?: 0 + val total = kept.size + newCount + if (total !in 1..3) { + throw MemberException( + HttpStatus.BAD_REQUEST, + "코드 이미지는 1~3개여야 합니다. (현재: 유지 ${kept.size}개 + 신규 ${newCount}개 = ${total}개)" + ) + } + + profile.codeImages.filter { it !in kept }.toList().forEach { img -> + profile.codeImages.remove(img) + } + + val added = codeImages.orEmpty().mapIndexed { idx, file -> + val url = imageUploader.uploadFile(file) + CodeImage(profile = profile, url = url, orders = kept.size + idx, isApproved = true) + } + added.forEach { img -> + profile.codeImages.add(img) + } + + profile.codeImages.sortedBy { it.orders }.forEachIndexed { i, img -> img.orders = i } + + profile.updateCodeImageUrls(profile.codeImages.sortedBy { it.orders }.map { it.url }) + + uploadedCount += newCount + messages += "코드 이미지 ${total}개 반영(유지 ${kept.size}, 신규 ${newCount})" + changed = true + } + + // 변경이 있었으면 재심사(정책에 맞게 조정) + if (changed) { + findMember.memberStatus = MemberStatus.PENDING + messages += "심사가 다시 진행됩니다" + } else { + messages += "변경된 이미지가 없습니다" + } + + // 영속 + @Transactional → save 호출 불필요 (merge 유발 금지) + return ReplaceImagesResponse( + uploadedCount = uploadedCount, + profileStatus = findMember.memberStatus, + message = messages.joinToString(". ") + ) + } + + /** + * 재심사 요청 (코드/얼굴/인증 이미지 통합 제출) + * - 2번 과정(코드/얼굴 이미지)과 3번 과정(인증 이미지)을 통합 + * - 모든 이미지를 한 번에 제출하여 PENDING 상태로 변경 + * + * @param member 회원 + * @param faceImages 얼굴 이미지 (신규 업로드) + * @param codeImages 코드 이미지 (신규 업로드) + * @param existingFaceImageIds 유지할 기존 얼굴 이미지 ID 목록 + * @param existingCodeImageIds 유지할 기존 코드 이미지 ID 목록 + * @param standardImageId 표준 인증 이미지 ID + * @param verificationImage 본인 인증 이미지 + */ + fun resubmitProfileForReview( + member: Member, + faceImages: List?, + codeImages: List?, + existingFaceImageIds: List?, + existingCodeImageIds: List?, + standardImageId: Long, + verificationImage: MultipartFile + ): ResubmitProfileResponse { + // 1. 회원 상태 검증: REJECT 상태여야 함 + require(member.memberStatus == MemberStatus.REJECT) { + "재심사 요청은 REJECT 상태에서만 가능합니다. 현재 상태: ${member.memberStatus}" + } + + val findMember = memberJpaRepository.findMemberWithProfile(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 조회할 수 없습니다.") + val profile = findMember.getProfileOrThrow() + + val messages = mutableListOf() + + // 2. 얼굴 이미지 처리 + if (!faceImages.isNullOrEmpty() || !existingFaceImageIds.isNullOrEmpty()) { + val keepIds = (existingFaceImageIds ?: emptyList()).toSet() + val kept = profile.faceImages + .sortedBy { it.orders } + .filter { it.id != null && it.id in keepIds } + + val newCount = faceImages?.size ?: 0 + val total = kept.size + newCount + if (total != 2) { + throw MemberException( + HttpStatus.BAD_REQUEST, + "얼굴 이미지는 총 2개여야 합니다. (현재: 유지 ${kept.size}개 + 신규 ${newCount}개 = ${total}개)" + ) + } + + profile.faceImages.filter { it !in kept }.toList().forEach { img -> + profile.faceImages.remove(img) + } + + val added = faceImages.orEmpty().mapIndexed { idx, file -> + val url = imageUploader.uploadFile(file) + FaceImage(profile = profile, url = url, orders = kept.size + idx, isApproved = true) + } + added.forEach { img -> + profile.faceImages.add(img) + } + + profile.faceImages.sortedBy { it.orders }.forEachIndexed { i, img -> img.orders = i } + profile.updateFaceImageUrls(profile.faceImages.sortedBy { it.orders }.map { it.url }) + + messages += "얼굴 이미지 2개 반영" + } + + // 3. 코드 이미지 처리 + if (!codeImages.isNullOrEmpty() || !existingCodeImageIds.isNullOrEmpty()) { + val keepIds = (existingCodeImageIds ?: emptyList()).toSet() + val kept = profile.codeImages + .sortedBy { it.orders } + .filter { it.id != null && it.id in keepIds } + + val newCount = codeImages?.size ?: 0 + val total = kept.size + newCount + if (total !in 1..3) { + throw MemberException( + HttpStatus.BAD_REQUEST, + "코드 이미지는 1~3개여야 합니다. (현재: 유지 ${kept.size}개 + 신규 ${newCount}개 = ${total}개)" + ) + } + + profile.codeImages.filter { it !in kept }.toList().forEach { img -> + profile.codeImages.remove(img) + } + + val added = codeImages.orEmpty().mapIndexed { idx, file -> + val url = imageUploader.uploadFile(file) + CodeImage(profile = profile, url = url, orders = kept.size + idx, isApproved = true) + } + added.forEach { img -> + profile.codeImages.add(img) + } + + profile.codeImages.sortedBy { it.orders }.forEachIndexed { i, img -> img.orders = i } + profile.updateCodeImageUrls(profile.codeImages.sortedBy { it.orders }.map { it.url }) + + messages += "코드 이미지 ${total}개 반영" + } + + // 4. 본인 인증 이미지 처리 + // 기존 인증 이미지가 있으면 소프트 딜리트 + val existingVerificationImage = verificationImageRepository + .findFirstByMemberAndDeletedAtIsNullOrderByCreatedAtDesc(findMember) + existingVerificationImage?.softDelete() + + // 표준 이미지 조회 + val standardImage = standardVerificationImageRepository.findById(standardImageId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "표준 인증 이미지를 찾을 수 없습니다. ID: $standardImageId") + } + + // S3에 인증 이미지 업로드 + val verificationImageUrl = imageUploader.uploadFile(verificationImage) + + // VerificationImage 엔티티 생성 및 저장 + val newVerificationImage = VerificationImage( + member = findMember, + standardVerificationImage = standardImage, + userImageUrl = verificationImageUrl + ) + verificationImageRepository.save(newVerificationImage) + + messages += "본인 인증 이미지 제출 완료" + + // 5. Member 상태를 HIDDEN_COMPLETED으로 변경 + findMember.memberStatus = MemberStatus.PENDING + messages += "심사 대기 상태로 변경되었습니다" + + return ResubmitProfileResponse( + status = findMember.memberStatus, + message = messages.joinToString(". "), + ) + } +} diff --git a/src/main/kotlin/codel/member/business/SignupService.kt b/src/main/kotlin/codel/member/business/SignupService.kt new file mode 100644 index 00000000..d38f1d78 --- /dev/null +++ b/src/main/kotlin/codel/member/business/SignupService.kt @@ -0,0 +1,260 @@ +package codel.member.business + +import codel.member.domain.CodeImageVO +import codel.member.domain.FaceImageVO +import codel.member.domain.ImageUploader +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.member.infrastructure.ProfileJpaRepository +import codel.member.presentation.request.EssentialProfileRequest +import codel.member.presentation.request.HiddenProfileRequest +import codel.member.presentation.request.PersonalityProfileRequest +import codel.question.business.QuestionService +import codel.verification.domain.VerificationImage +import codel.verification.infrastructure.StandardVerificationImageJpaRepository +import codel.verification.infrastructure.VerificationImageJpaRepository +import codel.verification.presentation.response.VerificationImageResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate + +@Service +@Transactional +class SignupService( + private val imageUploader: ImageUploader, + private val questionService: QuestionService, + private val memberJpaRepository: MemberJpaRepository, + private val profileJpaRepository: ProfileJpaRepository, + private val standardVerificationImageRepository: StandardVerificationImageJpaRepository, + private val verificationImageRepository: VerificationImageJpaRepository +) { + + /** + * 전화번호 인증 완료 처리 + */ + fun completePhoneVerification(member: Member) { + member.completePhoneVerification() + + // Profile 객체 생성 (빈 상태로) + member.createEmptyProfile() + + memberJpaRepository.save(member) + } + + /** + * Essential Profile 정보 등록 + */ + fun registerEssentialProfile(member: Member, request: EssentialProfileRequest) { + // 단계별 검증 +// member.validateCanProceedToEssential() + + // 요청 데이터 검증 + request.validateSelf() + + val profile = member.getProfileOrThrow() + profile.updateEssentialProfileInfo( + codeName = request.codeName, + birthDate = LocalDate.parse(request.birthDate), + sido = request.bigCity, + sigugun = request.smallCity, + jobCategory = request.jobCategory, + ) + memberJpaRepository.save(member) + } + + /** + * Essential Profile 이미지 등록 및 완료 처리 + */ + fun registerEssentialImages(member: Member, images: List) { + // 기본 정보가 먼저 등록되어 있는지 검증 + val findMember = memberJpaRepository.findByMemberId(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다.") + + val profile = findMember.getProfileOrThrow() + require(profile.codeName != null) { + "Essential Profile 정보를 먼저 등록해주세요" + } + + // 기존 이미지 업로드 로직 재활용 + val codeImage = uploadCodeImage(images) + + // Profile 이미지 업데이트 및 완료 처리 + profile.updateEssentialProfileImages(codeImage.urls) + + // Essential Profile 완료 상태로 변경 +// member.completeEssentialProfile() + + // 거절당했을 때, + if(findMember.memberStatus == MemberStatus.REJECT){ + findMember.memberStatus = MemberStatus.PENDING + } + + memberJpaRepository.save(findMember) + } + + /** + * 코드 이미지 업로드 (기존 MemberService 로직 재활용) + */ + private fun uploadCodeImage(files: List): CodeImageVO { + return CodeImageVO(files.map { file -> imageUploader.uploadFile(file) }) + } + + /** + * 얼굴 이미지 업로드 (추후 Hidden Profile에서 사용) + */ + private fun uploadFaceImage(files: List): FaceImageVO { + return FaceImageVO(files.map { file -> imageUploader.uploadFile(file) }) + } + + /** + * Personality Profile 등록 및 완료 처리 + */ + fun registerPersonalityProfile(member: Member, request: PersonalityProfileRequest) { + // 단계별 검증 +// member.validateCanProceedToPersonality() + + // 요청 데이터 검증 + request.validateSelf() + + // Question 조회 (질문이 있는 경우) + val representativeQuestion = request.questionId?.let { + questionService.findQuestionById(it) + } + + // Profile 정보 업데이트 + val profile = member.getProfileOrThrow() + profile.updatePersonalityProfile( + hairLength = request.hairLength, + bodyType = request.bodyType, + height = request.height, + styles = request.styles, + mbti = request.mbti, + alcohol = request.drinkingStyle, + smoke = request.smokingStyle, + personalities = request.personalities, + representativeQuestion = representativeQuestion, + representativeAnswer = request.answer, + interests = request.interests, + ) + + //TODO :: Personality Profile 완료 상태로 변경 ( 다음 업데이트까지 세이브포인트 생략 ) + member.completePersonalityProfile() + + if(member.memberStatus == MemberStatus.REJECT){ + member.memberStatus = MemberStatus.PENDING + } + + memberJpaRepository.save(member) + } + + /** + * Hidden Profile 정보 등록 + */ + fun registerHiddenProfile(member: Member, request: HiddenProfileRequest) { + // 단계별 검증 + + val findMember = memberJpaRepository.findByMemberId(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다.") + +// member.validateCanProceedToHidden() + + // Profile 정보 업데이트 (이미지 제외) + val profile = findMember.getProfileOrThrow() + profile.updateHiddenProfileInfo( + loveLanguage = request.loveLanguage, + affectionStyle = request.affectionStyle, + contactStyle = request.contactStyle, + dateStyle = request.dateStyle, + conflictResolutionStyle = request.conflictResolutionStyle, + relationshipValues = request.relationshipValues + ) + memberJpaRepository.save(findMember) + } + + /** + * Hidden Profile 이미지 등록 및 완료 처리 + */ + fun registerHiddenImages(member: Member, images: List) { + // Hidden Profile 정보가 먼저 등록되어 있는지 검증 + val findMember = memberJpaRepository.findByMemberId(member.getIdOrThrow()) + ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원을 찾을 수 없습니다.") + + val profile = findMember.getProfileOrThrow() + require(profile.loveLanguage != null) { + "Hidden Profile 정보를 먼저 등록해주세요" + } + + // 기존 얼굴 이미지가 있으면 컬렉션에서 제거 (orphanRemoval로 DB 삭제) + // 재제출 시 (REJECT 상태에서 다시 제출) 기존 이미지 삭제 처리 + if (profile.faceImages.isNotEmpty()) { + profile.faceImages.clear() + // S3 삭제는 트랜잭션 커밋 후 비동기로 권장 (이벤트/리스너) + } + + // 기존 이미지 업로드 로직 재활용 + val faceImage = uploadFaceImage(images) + + // Profile 이미지 업데이트 및 완료 처리 + profile.updateHiddenProfileImages(faceImage.urls) + + // Hidden Profile 완료 상태로 변경 +// findMember.completeHiddenProfile() + + memberJpaRepository.save(findMember) + } + + /** + * 사용자 인증 이미지 제출 (회원가입 절차의 일부) + * + * @param member 인증 이미지를 제출하는 회원 + * @param standardImageId 참조한 표준 이미지 ID + * @param userImageFile 사용자가 촬영한 이미지 파일 + * @return 제출 결과 + */ + fun submitVerificationImage( + member: Member, + standardImageId: Long, + userImageFile: MultipartFile + ): VerificationImageResponse { + // 1. 회원 상태 검증: HIDDEN_COMPLETED 또는 REJECT 상태여야 함 + val validStatuses = listOf(MemberStatus.PERSONALITY_COMPLETED) + require(member.memberStatus in validStatuses) { + "인증 이미지 제출은 PERSONALITY_COMPLETED 상태에서만 가능합니다. 현재 상태: ${member.memberStatus}" + } + + // 2. 기존 인증 이미지가 있으면 소프트 딜리트 처리 (이력 유지) + val existingImage = verificationImageRepository.findFirstByMemberAndDeletedAtIsNullOrderByCreatedAtDesc(member) + existingImage?.softDelete() + + // 3. 표준 이미지 조회 + val standardImage = standardVerificationImageRepository.findById(standardImageId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "표준 인증 이미지를 찾을 수 없습니다. ID: $standardImageId") + } + + // 4. S3에 이미지 업로드 + val userImageUrl = imageUploader.uploadFile(userImageFile) + + // 5. VerificationImage 엔티티 생성 및 저장 + val verificationImage = VerificationImage( + member = member, + standardVerificationImage = standardImage, + userImageUrl = userImageUrl + ) + verificationImageRepository.save(verificationImage) + + // 6. Member 상태를 PENDING으로 변경 + member.memberStatus = MemberStatus.PENDING + memberJpaRepository.save(member) + + return VerificationImageResponse.from( + memberId = member.getIdOrThrow(), + memberStatus = member.memberStatus, + verificationImage = verificationImage + ) + } +} diff --git a/src/main/kotlin/codel/member/business/signup/PostVerificationStrategy.kt b/src/main/kotlin/codel/member/business/signup/PostVerificationStrategy.kt new file mode 100644 index 00000000..04642c89 --- /dev/null +++ b/src/main/kotlin/codel/member/business/signup/PostVerificationStrategy.kt @@ -0,0 +1,36 @@ +package codel.member.business.signup + +import codel.config.Loggable +import codel.member.business.SignupService +import codel.member.domain.Member +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile + +/** + * 본인인증 기능 추가 후 전략 + * + * 본인인증 이미지 제출 기능이 추가된 신규 앱(1.2.0 이상)용 전략입니다. + * 히든 프로필 이미지 제출 후, 별도의 본인인증 이미지 제출이 필요합니다. + * 재심사의 경우 새로운 재심사 전용 API(/v1/profile/review/resubmit)를 사용해야 합니다. + */ +@Component +class PostVerificationStrategy( + private val signupService: SignupService +) : SignupStrategy, Loggable { + + override fun handleHiddenImages( + member: Member, + images: List + ): ResponseEntity { + log.info { + "본인인증 후 플로우 - userId: ${member.getIdOrThrow()}, " + + "appVersion: >= 1.2.0" + } + + // SignupService의 registerHiddenImages 호출 (히든 이미지만 등록) + signupService.registerHiddenImages(member, images) + + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/codel/member/business/signup/PreVerificationStrategy.kt b/src/main/kotlin/codel/member/business/signup/PreVerificationStrategy.kt new file mode 100644 index 00000000..cd9ea528 --- /dev/null +++ b/src/main/kotlin/codel/member/business/signup/PreVerificationStrategy.kt @@ -0,0 +1,64 @@ +package codel.member.business.signup + +import codel.config.Loggable +import codel.member.business.SignupService +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +/** + * 본인인증 기능 추가 전 전략 + * + * 본인인증 이미지 제출 기능이 없던 구버전 앱(1.2.0 미만)용 전략입니다. + * 히든 프로필 이미지 제출 시 회원 상태에 따라 다르게 처리합니다: + * - PERSONALITY_COMPLETED: 히든 이미지 등록 후 HIDDEN_COMPLETED 상태로 변경 (정상 회원가입 완료) + */ +@Component +class PreVerificationStrategy( + private val signupService: SignupService, + private val memberJpaRepository: MemberJpaRepository, + private val asyncNotificationService: IAsyncNotificationService +) : SignupStrategy, Loggable { + + @Transactional + override fun handleHiddenImages( + member: Member, + images: List + ): ResponseEntity { + log.info { + "본인인증 전 플로우 - userId: ${member.getIdOrThrow()}, " + + "status: ${member.memberStatus}, appVersion: < 1.2.0" + } + + // 히든 이미지 등록 (기존 SignupService 로직 재활용) + signupService.registerHiddenImages(member, images) + + member.completeHiddenProfile() + memberJpaRepository.save(member) + log.info { + "정상 가입 플로우 완료 - userId: ${member.getIdOrThrow()}, " + + "status: HIDDEN_COMPLETED" + } + + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "${member.getProfileOrThrow().getCodeNameOrThrow()}님이 심사를 요청하였습니다.", + body = "code:L 프로필 심사 요청이 왔습니다.", + ), + ) + + return ResponseEntity.ok().build() + } +} diff --git a/src/main/kotlin/codel/member/business/signup/SignupStrategy.kt b/src/main/kotlin/codel/member/business/signup/SignupStrategy.kt new file mode 100644 index 00000000..d9c49184 --- /dev/null +++ b/src/main/kotlin/codel/member/business/signup/SignupStrategy.kt @@ -0,0 +1,25 @@ +package codel.member.business.signup + +import codel.member.domain.Member +import org.springframework.http.ResponseEntity +import org.springframework.web.multipart.MultipartFile + +/** + * 회원가입 히든 이미지 등록 전략 인터페이스 + * + * 앱 버전과 회원 상태에 따라 다른 동작을 수행하기 위한 전략 패턴 + */ +interface SignupStrategy { + + /** + * 히든 이미지 등록 처리 + * + * @param member 로그인한 회원 + * @param images 업로드할 이미지 파일 목록 + * @return 처리 결과 응답 + */ + fun handleHiddenImages( + member: Member, + images: List + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/member/business/signup/SignupStrategyResolver.kt b/src/main/kotlin/codel/member/business/signup/SignupStrategyResolver.kt new file mode 100644 index 00000000..dcdf4046 --- /dev/null +++ b/src/main/kotlin/codel/member/business/signup/SignupStrategyResolver.kt @@ -0,0 +1,81 @@ +package codel.member.business.signup + +import codel.config.Loggable +import org.springframework.stereotype.Component + +/** + * 회원가입 전략 선택 Resolver + * + * 앱 버전을 기반으로 적절한 SignupStrategy를 선택합니다. + * - 1.2.0 미만: 본인인증 기능 추가 전 전략 (PreVerificationStrategy) + * - 1.2.0 이상: 본인인증 기능 추가 후 전략 (PostVerificationStrategy) + */ +@Component +class SignupStrategyResolver( + private val postVerificationStrategy: PostVerificationStrategy, + private val preVerificationStrategy: PreVerificationStrategy +) : Loggable { + + /** + * 앱 버전에 따라 적절한 전략을 선택합니다. + * + * @param appVersion 앱 버전 (X-App-Version 헤더) + * @return 선택된 전략 + */ + fun resolveStrategy(appVersion: String?): SignupStrategy { + log.debug { + "전략 선택 시작 - appVersion: $appVersion" + } + + return when { + // 신규 앱 (1.2.0 이상) → 본인인증 후 전략 + isNewApp(appVersion) -> { + log.info { + "PostVerificationStrategy 선택 - appVersion: $appVersion" + } + postVerificationStrategy + } + + // 구버전 앱 (1.2.0 미만) → 본인인증 전 전략 + else -> { + log.info { + "PreVerificationStrategy 선택 - appVersion: ${appVersion ?: "null"}" + } + preVerificationStrategy + } + } + } + + /** + * 신규 앱 버전인지 판단 + * + * 1.2.0 이상이면 신규 앱으로 간주합니다. + * + * @param version 앱 버전 문자열 (예: "1.2.0") + * @return 신규 앱 여부 + */ + private fun isNewApp(version: String?): Boolean { + if (version == null) { + log.debug { "앱 버전 null → 구버전으로 간주" } + return false + } + + return try { + val parts = version.split(".") + val major = parts.getOrNull(0)?.toIntOrNull() ?: 0 + val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0 + + // 1.2.0 이상이면 신규 앱 + val isNew = major > 1 || (major == 1 && minor >= 2) + + log.debug { + "앱 버전 파싱: $version → major=$major, minor=$minor, isNew=$isNew" + } + + isNew + } catch (e: Exception) { + log.warn(e) { "앱 버전 파싱 실패: $version → 구버전으로 간주" } + false // 파싱 실패 시 안전하게 구버전으로 간주 + } + } +} diff --git a/src/main/kotlin/codel/member/domain/AccessLevel.kt b/src/main/kotlin/codel/member/domain/AccessLevel.kt new file mode 100644 index 00000000..7ee9ca6c --- /dev/null +++ b/src/main/kotlin/codel/member/domain/AccessLevel.kt @@ -0,0 +1,16 @@ +package codel.member.domain + +enum class AccessLevel(val description: String) { + SELF("본인 프로필"), + CODE_EXCHANGED("코드 교환 완료"), + PUBLIC("일반 공개 프로필"), + RESTRICTED("제한적 접근"); + + fun canViewHidden(): Boolean { + return this in listOf(SELF, CODE_EXCHANGED) + } + + fun canEdit(): Boolean { + return this == SELF + } +} diff --git a/src/main/kotlin/codel/member/domain/CodeImage.kt b/src/main/kotlin/codel/member/domain/CodeImage.kt new file mode 100644 index 00000000..476cc8c1 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/CodeImage.kt @@ -0,0 +1,42 @@ +package codel.member.domain + +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* + +@Entity +@Table(name = "code_images") +class CodeImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", nullable = false) + val profile: Profile, + + @Column(nullable = false, length = 500) + val url: String, + + @Column(nullable = false) + var orders: Int, + + @Column(nullable = false) + var isApproved: Boolean = true, + + @Column(length = 1000) + var rejectionReason: String? = null +) : BaseTimeEntity() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CodeImage) return false + if (id == 0L) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() + + override fun toString(): String { + return "CodeImage(id=$id, url='$url', order=$orders, isApproved=$isApproved)" + } +} diff --git a/src/main/kotlin/codel/member/domain/CodeImageVO.kt b/src/main/kotlin/codel/member/domain/CodeImageVO.kt new file mode 100644 index 00000000..3a037921 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/CodeImageVO.kt @@ -0,0 +1,16 @@ +package codel.member.domain + +import codel.member.exception.MemberException +import org.springframework.http.HttpStatus + +class CodeImageVO( + val urls: List, +) { + init { + require(urls.size in 1..3) { + throw MemberException(HttpStatus.BAD_REQUEST, "코드 이미지 URL은 1개 이상 3개 이하이어야 합니다.") + } + } + + fun serializeAttribute(): String = urls.joinToString(separator = ",") +} diff --git a/src/main/kotlin/codel/member/domain/DailySeedProvider.kt b/src/main/kotlin/codel/member/domain/DailySeedProvider.kt new file mode 100644 index 00000000..2778b4e1 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/DailySeedProvider.kt @@ -0,0 +1,37 @@ +package codel.member.domain + +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.* +import kotlin.math.absoluteValue + +class DailySeedProvider { + companion object { + fun generateDailySeedForMember(memberId: Long): Long { + val today = LocalDate.now() + val key = "$memberId-$today" + return key.hashCode().toLong().absoluteValue + } + + fun generateRandomSeed(): Long { + val uuid = UUID.randomUUID() + return (uuid.mostSignificantBits xor uuid.leastSignificantBits).absoluteValue + } + + fun generateMemberSeedEveryTenHours + (memberId: Long) : Long{ + val now = LocalDateTime.now() + val baseDate = if (now.hour < 10) { + // 아직 오전 10시 전이면 어제 날짜로 간주 + now.toLocalDate().minusDays(1) + } else { + // 오전 10시 이후면 오늘 날짜 + now.toLocalDate() + } + val isDayBlock = now.hour in 10..21 // 10:00 ~ 21:59 + + val key = "$memberId-$baseDate-BLOCK-${if (isDayBlock) "DAY" else "NIGHT"}" + return key.hashCode().toLong().absoluteValue + } + } +} diff --git a/src/main/kotlin/codel/member/domain/FaceImage.kt b/src/main/kotlin/codel/member/domain/FaceImage.kt new file mode 100644 index 00000000..f3f2a33f --- /dev/null +++ b/src/main/kotlin/codel/member/domain/FaceImage.kt @@ -0,0 +1,42 @@ +package codel.member.domain + +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* + +@Entity +@Table(name = "face_images") +class FaceImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", nullable = false) + val profile: Profile, + + @Column(nullable = false, length = 500) + val url: String, + + @Column(nullable = false) + var orders: Int, + + @Column(nullable = false) + var isApproved: Boolean = true, + + @Column(length = 1000) + var rejectionReason: String? = null +) : BaseTimeEntity() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FaceImage) return false + if (id == 0L) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() + + override fun toString(): String { + return "FaceImage(id=$id, url='$url', order=$orders, isApproved=$isApproved)" + } +} diff --git a/src/main/kotlin/codel/member/domain/FaceImageVO.kt b/src/main/kotlin/codel/member/domain/FaceImageVO.kt new file mode 100644 index 00000000..d9b6963b --- /dev/null +++ b/src/main/kotlin/codel/member/domain/FaceImageVO.kt @@ -0,0 +1,16 @@ +package codel.member.domain + +import codel.member.exception.MemberException +import org.springframework.http.HttpStatus + +class FaceImageVO( + val urls: List, +) { + init { + if (urls.size != 2) { + throw MemberException(HttpStatus.BAD_REQUEST, "얼굴 이미지 URL은 정확히 2개여야 합니다.") + } + } + + fun serializeAttribute(): String = urls.joinToString(separator = ",") +} diff --git a/src/main/kotlin/codel/member/domain/ImageUploader.kt b/src/main/kotlin/codel/member/domain/ImageUploader.kt new file mode 100644 index 00000000..519d54b6 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/ImageUploader.kt @@ -0,0 +1,7 @@ +package codel.member.domain + +import org.springframework.web.multipart.MultipartFile + +interface ImageUploader { + fun uploadFile(file: MultipartFile): String +} diff --git a/src/main/kotlin/codel/member/domain/Member.kt b/src/main/kotlin/codel/member/domain/Member.kt new file mode 100644 index 00000000..74392cff --- /dev/null +++ b/src/main/kotlin/codel/member/domain/Member.kt @@ -0,0 +1,197 @@ +package codel.member.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.exception.MemberException +import jakarta.persistence.* +import org.springframework.http.HttpStatus +import java.time.LocalDate +import kotlin.math.log + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["oauthType", "oauthId"]), + ], +) +class Member( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + var email: String, + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + var profile: Profile? = null, + var fcmToken: String? = null, + @Enumerated(EnumType.STRING) + var oauthType: OauthType, + var oauthId: String, + @Enumerated(EnumType.STRING) + var memberStatus: MemberStatus, + + var withdrawnReason : String? = null, + + var rejectReason: String? = null, +) : BaseTimeEntity() { + fun getIdOrThrow(): Long = id ?: throw MemberException(HttpStatus.BAD_REQUEST, "id가 없는 멤버 입니다.") + + fun getProfileOrThrow(): Profile = profile ?: throw MemberException(HttpStatus.BAD_REQUEST, "프로필이 없는 멤버입니다.") + + fun validateRejectedOrThrow() { + takeIf { memberStatus == MemberStatus.REJECT } + ?: throw MemberException(HttpStatus.BAD_REQUEST, "심사 거절된 멤버가 아닙니다.") + } + + fun isNotDone(): Boolean = memberStatus != MemberStatus.DONE + + fun updateProfile(profile: Profile) { + // 양방향 연관관계 설정 + this.profile = profile + profile.member = this + } + + /** + * 빈 Profile 생성 (전화번호 인증 완료 시 사용) + */ + fun createEmptyProfile() { + if (this.profile == null) { + val profile = Profile() + updateProfile(profile) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Member) return false + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + return id?.hashCode() ?: 0 + } + + // ===== 회원가입 단계 검증 및 상태 전환 메서드들 ===== + + /** + * 전화번호 인증을 완료하고 상태를 PHONE_VERIFIED로 변경 + */ + fun completePhoneVerification() { + require(memberStatus == MemberStatus.SIGNUP) { + "전화번호 인증은 회원가입 직후에만 가능합니다. 현재 상태: $memberStatus" + } + memberStatus = MemberStatus.PHONE_VERIFIED + } + + /** + * Essential Profile 등록이 가능한지 확인 + */ + fun canProceedToEssential(): Boolean { + return memberStatus == MemberStatus.PHONE_VERIFIED + } + + /** + * Essential Profile 등록 가능 여부 검증 + */ + fun validateCanProceedToEssential() { + require(memberStatus == MemberStatus.PHONE_VERIFIED) { + "Essential Profile 등록은 전화번호 인증 완료 후에만 가능합니다. 현재 상태: $memberStatus" + } + } + + /** + * Essential Profile 완료 상태로 변경 + */ + fun completeEssentialProfile() { +// validateCanProceedToEssential() + memberStatus = MemberStatus.ESSENTIAL_COMPLETED + } + + /** + * Personality Profile 등록이 가능한지 확인 + */ + fun canProceedToPersonality(): Boolean { + return memberStatus == MemberStatus.ESSENTIAL_COMPLETED + } + + /** + * Personality Profile 등록 가능 여부 검증 + */ + fun validateCanProceedToPersonality() { + require(memberStatus == MemberStatus.ESSENTIAL_COMPLETED) { + "Personality Profile 등록은 Essential Profile 완료 후에만 가능합니다. 현재 상태: $memberStatus" + } + } + + /** + * Personality Profile 완료 상태로 변경 + */ + fun completePersonalityProfile() { +// validateCanProceedToPersonality() + memberStatus = MemberStatus.PERSONALITY_COMPLETED + } + + /** + * Hidden Profile 등록이 가능한지 확인 + */ + fun canProceedToHidden(): Boolean { + return memberStatus == MemberStatus.PERSONALITY_COMPLETED + } + + /** + * Hidden Profile 등록 가능 여부 검증 + */ + fun validateCanProceedToHidden() { + require(memberStatus == MemberStatus.PERSONALITY_COMPLETED) { + "Hidden Profile 등록은 Personality Profile 완료 후에만 가능합니다. 현재 상태: $memberStatus" + } + } + + /** + * Hidden Profile 완료 상태로 변경 + */ + fun completeHiddenProfile() { +// validateCanProceedToHidden() + memberStatus = MemberStatus.PENDING + } + + /** + * 현재 상태에서 다음 진행 가능한 단계 반환 + */ + fun getNextAvailableStep(): MemberStatus? { + return when (memberStatus) { + MemberStatus.SIGNUP -> MemberStatus.PHONE_VERIFIED + MemberStatus.PHONE_VERIFIED -> MemberStatus.ESSENTIAL_COMPLETED + MemberStatus.ESSENTIAL_COMPLETED -> MemberStatus.HIDDEN_COMPLETED + MemberStatus.PERSONALITY_COMPLETED -> MemberStatus.HIDDEN_COMPLETED + MemberStatus.HIDDEN_COMPLETED -> MemberStatus.PENDING // 인증 이미지 제출 후 PENDING + MemberStatus.PENDING -> MemberStatus.PENDING + MemberStatus.REJECT -> MemberStatus.REJECT + else -> null + } + } + + fun reject(rejectReason: String) { + this.memberStatus = MemberStatus.REJECT + this.rejectReason = rejectReason + } + + // ===== 회원 탈퇴 관련 메서드들 (신규 추가) ===== + + /** + * 회원 탈퇴 처리 + */ + fun withdraw(reason : String) { + this.withdrawnReason = reason + this.memberStatus = MemberStatus.WITHDRAWN + } + + /** + * 탈퇴한 회원인지 확인 + */ + fun isWithdrawn(): Boolean = memberStatus == MemberStatus.WITHDRAWN + + fun getUpdateDate() : LocalDate{ + return updatedAt.toLocalDate() + } +} diff --git a/src/main/kotlin/codel/member/domain/MemberRepository.kt b/src/main/kotlin/codel/member/domain/MemberRepository.kt new file mode 100644 index 00000000..c7be8941 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/MemberRepository.kt @@ -0,0 +1,123 @@ +package codel.member.domain + +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.member.infrastructure.ProfileJpaRepository +import codel.member.infrastructure.RejectReasonJpaRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component + +@Component +class MemberRepository( + private val memberJpaRepository: MemberJpaRepository, + private val profileJpaRepository: ProfileJpaRepository, + private val rejectReasonJpaRepository: RejectReasonJpaRepository, +) { + fun loginMember(member: Member): Member { + if (memberJpaRepository.existsByOauthTypeAndOauthId(member.oauthType, member.oauthId)) { + return findMember(member.oauthType, member.oauthId) + } + return try { + memberJpaRepository.save(member) + } catch (e: DataIntegrityViolationException) { + throw MemberException(HttpStatus.BAD_REQUEST, "이미 회원이 존재합니다.") + } + } + + fun findMember( + oauthType: OauthType, + oauthId: String, + ): Member = memberJpaRepository.findByOauthTypeAndOauthId(oauthType, oauthId) + + fun findMember(memberId: Long): Member = findMemberById(memberId) + + private fun findMemberById(memberId: Long) = + memberJpaRepository.findMemberWithProfileAndQuestion(memberId) ?: throw MemberException( + HttpStatus.BAD_REQUEST, + "해당 id에 일치하는 멤버가 없습니다.", + ) + + fun findDoneMember(memberId: Long): Member { + val member = findMemberById(memberId) + if (member.memberStatus != MemberStatus.DONE) { + throw MemberException(HttpStatus.BAD_REQUEST, "해당 멤버는 회원가입을 완료하지 않았습니다.") + } + return member + } + + fun updateMemberProfile( + member: Member, + profile: Profile, + ) { + member.profile = profile + memberJpaRepository.save(member) + } + + fun saveRejectReason( + member: Member, + rejectReason: String, + ) { + val member = findMemberById(member.getIdOrThrow()) + val memberRejectReason = + RejectReason( + member = member, + reason = rejectReason, + ) + rejectReasonJpaRepository.save(memberRejectReason) + } + + fun findPendingMembers(): List = memberJpaRepository.findByMemberStatus(MemberStatus.PENDING) + + fun findRejectReason(member: Member): String { + member.validateRejectedOrThrow() + val findMember = findMemberById(member.getIdOrThrow()) + val rejectReasonEntity = findRejectReasonByMemberOrThrow(findMember) + + return rejectReasonEntity.reason + } + + private fun findRejectReasonByMemberOrThrow(member: Member): RejectReason = + rejectReasonJpaRepository.findByMember(member) + ?: throw MemberException( + HttpStatus.BAD_REQUEST, + "거절 사유가 존재하지 않는 멤버입니다.", + ) + + fun updateMember(member: Member): Member = memberJpaRepository.save(member) + + fun saveProfile(profile: Profile): Profile = profileJpaRepository.save(profile) + + fun updateMemberCodeImage( + profile: Profile, + serializeCodeImages: String, + ) { + profile.codeImage = serializeCodeImages + profileJpaRepository.save(profile) + } + + fun updateMemberFaceImage( + profile: Profile, + serializeFaceImage: String, + ) { + profile.faceImage = serializeFaceImage + profileJpaRepository.save(profile) + } + + fun updateMemberFcmToken( + member: Member, + fcmToken: String, + ) { + member.fcmToken = fcmToken + memberJpaRepository.save(member) + } + + fun findMemberWithProfileAndQuestion(id: Long) : Member? { + return memberJpaRepository.findMemberWithProfileAndQuestion(id) + } + + fun findByMemberStatus(memberStatus: MemberStatus): List { + return memberJpaRepository.findByMemberStatus(memberStatus) + } +} diff --git a/src/main/kotlin/codel/member/domain/MemberStatus.kt b/src/main/kotlin/codel/member/domain/MemberStatus.kt new file mode 100644 index 00000000..9573cfe5 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/MemberStatus.kt @@ -0,0 +1,14 @@ +package codel.member.domain + +enum class MemberStatus { + SIGNUP, // 회원가입 (기존) + PHONE_VERIFIED, // 1단계: 전화번호 인증 완료 (신규) + ESSENTIAL_COMPLETED, // 2단계: 기본 프로필 완료 (기존 CODE_SURVEY 대체) + PERSONALITY_COMPLETED, // 3단계: 성격/취향 프로필 완료 (신규) + HIDDEN_COMPLETED, // 4단계: 히든 프로필 완료, 인증 이미지 제출 대기 (기존 CODE_PROFILE_IMAGE 대체) + PENDING, // 관리자 심사 중 (기존) + REJECT, // 심사 거절 (기존) + DONE, // 최종 승인 완료 (기존) + WITHDRAWN, // 회원탈퇴 (신규) + ADMIN, +} diff --git a/src/main/kotlin/codel/member/domain/OauthType.kt b/src/main/kotlin/codel/member/domain/OauthType.kt new file mode 100644 index 00000000..d0eb2d63 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/OauthType.kt @@ -0,0 +1,8 @@ +package codel.member.domain + +enum class OauthType { + KAKAO, + APPLE, + GOOGLE, + ADMIN, +} diff --git a/src/main/kotlin/codel/member/domain/Profile.kt b/src/main/kotlin/codel/member/domain/Profile.kt new file mode 100644 index 00000000..00c662f5 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/Profile.kt @@ -0,0 +1,520 @@ +package codel.member.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.exception.MemberException +import codel.question.domain.Question +import jakarta.persistence.* +import org.springframework.http.HttpStatus +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.Period +import kotlin.collections.isNotEmpty + +@Entity +@Table(name = "profiles") +class Profile( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + // ===== 기본 프로필 섹션 (Essential Profile) ===== + var codeName: String? = null, + + @Column(nullable = true) + var birthDate: LocalDate? = null, // 생년월일로 변경 + + var bigCity: String? = null, + var smallCity: String? = null, + var job: String? = null, + var interests: String? = null, + + @Column(length = 1000) + var codeImage: String? = null, + + @OneToMany(mappedBy = "profile", cascade = [CascadeType.ALL], orphanRemoval = true) + val codeImages: MutableList = mutableListOf(), + + @Column(nullable = false) + var essentialCompleted: Boolean = false, + + @Column + var essentialCompletedAt: LocalDateTime? = null, + + // ===== 성격/취향 프로필 섹션 (Personality Profile) ===== + var hairLength: String? = null, + var bodyType: String? = null, + var height: Int? = null, + var style: String? = null, + var mbti: String? = null, + var alcohol: String? = null, + var smoke: String? = null, + var personalities: String? = null, + + + // 대표 질문 (Question 엔티티와 다대1 관계) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "representative_question_id") + var representativeQuestion: Question? = null, + + var representativeAnswer: String? = null, + + @Column(nullable = false) + var personalityCompleted: Boolean = false, + + @Column + var personalityCompletedAt: LocalDateTime? = null, + + // ===== 히든 프로필 섹션 (Hidden Profile) ===== + var loveLanguage: String? = null, + var affectionStyle: String? = null, + var contactStyle: String? = null, + var dateStyle: String? = null, + var conflictResolutionStyle: String? = null, + var relationshipValues: String? = null, + + @Column(length = 1000) + var faceImage: String? = null, + + @OneToMany(mappedBy = "profile", cascade = [CascadeType.ALL], orphanRemoval = true) + val faceImages: MutableList = mutableListOf(), + + @Column(nullable = false) + var hiddenCompleted: Boolean = false, + + @Column + var hiddenCompletedAt: LocalDateTime? = null, + + // ===== 기타 ===== + var introduce: String? = null, // 자기소개 (선택사항) + + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "member_id") + var member: Member? = null, +) : BaseTimeEntity(){ + + companion object { + // List ↔ String 변환 유틸리티 + private fun serializeList(list: List): String = + list.filter { it.isNotBlank() }.joinToString(",") + + private fun deserializeString(str: String?): List = + str?.split(",")?.filter { it.isNotBlank() } ?: emptyList() + } + + // ===== 나이 계산 ===== + fun getAge(): Int { + return birthDate?.let { + Period.between(it, LocalDate.now()).years + } ?: throw IllegalStateException("생년월일이 설정되지 않았습니다.") + } + + // ===== 자기 관리 메서드들 ===== + fun updateEssentialProfileInfo( + codeName: String, + birthDate: LocalDate, + sido: String, + sigugun: String, + jobCategory: String, + ) { +// validateEssentialInfoDomainRules(birthDate) + + this.codeName = codeName + this.birthDate = birthDate + this.bigCity = sido + this.smallCity = sigugun + this.job = jobCategory + + this.updatedAt = LocalDateTime.now() + } + + fun updateEssentialProfileImages( + codeImages: List + ) { + require(codeName != null) { "기본 정보를 먼저 입력해주세요" } + + // 1. 기존 String 필드 업데이트 (하위 호환성) + this.codeImage = serializeList(codeImages) + + // 2. 새로운 Entity 업데이트 (Dual Write) + this.codeImages.clear() + codeImages.forEachIndexed { index, url -> + this.codeImages.add( + CodeImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + this.essentialCompleted = true + this.essentialCompletedAt = LocalDateTime.now() + this.updatedAt = LocalDateTime.now() + } + + fun updateEssentialProfile( + codeName: String, + birthDate: LocalDate, + sido: String, + sigugun: String, + jobCategory: String, + codeImages: List, + interests: List + ) { +// validateEssentialDomainRules(birthDate, codeImages) + + this.codeName = codeName + this.birthDate = birthDate + this.bigCity = sido + this.smallCity = sigugun + this.job = jobCategory + this.interests = serializeList(interests) + + // 1. String 필드 업데이트 + this.codeImage = serializeList(codeImages) + + // 2. Entity 업데이트 (Dual Write) + this.codeImages.clear() + codeImages.forEachIndexed { index, url -> + this.codeImages.add( + CodeImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + this.essentialCompleted = true + this.essentialCompletedAt = LocalDateTime.now() + this.updatedAt = LocalDateTime.now() + } + + fun updatePersonalityProfile( + hairLength: String?, + bodyType: String?, + height: Int?, + styles: List, + mbti: String?, + alcohol: String?, + smoke: String?, + personalities: List, + interests: List, + representativeQuestion: Question?, + representativeAnswer: String? + + ) { +// validatePersonalityDomainRules(personalities, height, mbti, interests) + + this.hairLength = hairLength + this.bodyType = bodyType + this.height = height + this.style = serializeList(styles) + this.mbti = mbti + this.alcohol = alcohol + this.smoke = smoke + this.personalities = serializeList(personalities) + this.representativeQuestion = representativeQuestion + this.representativeAnswer = representativeAnswer + + this.personalityCompleted = true + this.personalityCompletedAt = LocalDateTime.now() + this.updatedAt = LocalDateTime.now() + this.interests = serializeList(interests) + } + + fun updateHiddenProfile( + loveLanguage: String, + affectionStyle: String, + contactStyle: String, + dateStyle: String, + conflictResolutionStyle: String, + relationshipValues: String, + faceImages: List + ) { +// validateHiddenDomainRules(faceImages) + + this.loveLanguage = loveLanguage + this.affectionStyle = affectionStyle + this.contactStyle = contactStyle + this.dateStyle = dateStyle + this.conflictResolutionStyle = conflictResolutionStyle + this.relationshipValues = relationshipValues + + // 1. String 필드 업데이트 + this.faceImage = serializeList(faceImages) + + // 2. Entity 업데이트 (Dual Write) + this.faceImages.clear() + faceImages.forEachIndexed { index, url -> + this.faceImages.add( + FaceImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + this.hiddenCompleted = true + this.hiddenCompletedAt = LocalDateTime.now() + this.updatedAt = LocalDateTime.now() + } + + fun updateHiddenProfileInfo( + loveLanguage: String, + affectionStyle: String, + contactStyle: String, + dateStyle: String, + conflictResolutionStyle: String, + relationshipValues: String + ) { +// validateHiddenInfoDomainRules() + + this.loveLanguage = loveLanguage + this.affectionStyle = affectionStyle + this.contactStyle = contactStyle + this.dateStyle = dateStyle + this.conflictResolutionStyle = conflictResolutionStyle + this.relationshipValues = relationshipValues + + this.updatedAt = LocalDateTime.now() + } + + fun updateHiddenProfileImages( + faceImages: List + ) { + require(loveLanguage != null) { "Hidden Profile 정보를 먼저 입력해주세요" } + + // 1. 기존 String 필드 업데이트 (하위 호환성) + this.faceImage = serializeList(faceImages) + + // 2. 새로운 Entity 업데이트 (Dual Write) + this.faceImages.clear() + faceImages.forEachIndexed { index, url -> + this.faceImages.add( + FaceImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + this.hiddenCompleted = true + this.hiddenCompletedAt = LocalDateTime.now() + this.updatedAt = LocalDateTime.now() + } + + // ===== 상태 조회 ===== + fun isPublicProfileComplete(): Boolean = essentialCompleted && personalityCompleted + fun isFullProfileComplete(): Boolean = essentialCompleted && personalityCompleted && hiddenCompleted + + fun getNextRequiredStep(): String? { + return when { + !essentialCompleted -> "ESSENTIAL" + !personalityCompleted -> "PERSONALITY" + !hiddenCompleted -> "HIDDEN" + else -> null + } + } + + // ===== 리스트 접근 메서드들 ===== + fun getInterestsList(): List = deserializeString(interests) + fun getPersonalitiesList(): List = deserializeString(personalities) + fun getStylesList(): List = deserializeString(style) + + fun getCodeImageList(): List { + // Entity가 있으면 Entity에서, 없으면 String 필드에서 (하위 호환성) +// return if (codeImages.isNotEmpty()) { +// codeImages.sortedBy { it.orders }.map { it.url } +// } else { + return deserializeString(codeImage) +// } + } + + fun getFaceImageList(): List { + // Entity가 있으면 Entity에서, 없으면 String 필드에서 (하위 호환성) +// return if (faceImages.isNotEmpty()) { +// faceImages.sortedBy { it.orders }.map { it.url } +// } else { + return deserializeString(faceImage) +// } + } + + // 기존 호환성 메서드 유지 + fun getCodeImageOrThrow(): List { + val images = getCodeImageList() + if (images.isEmpty()) { + throw MemberException(HttpStatus.BAD_REQUEST, "코드 이미지가 존재하지 않습니다.") + } + return images + } + + fun getFaceImageOrThrow(): List { + val images = getFaceImageList() + if (images.isEmpty()) { + throw MemberException(HttpStatus.BAD_REQUEST, "얼굴 이미지가 존재하지 않습니다.") + } + return images + } + + // ===== 도메인 검증 (비즈니스 규칙만) ===== + private fun validateEssentialInfoDomainRules( + birthDate: LocalDate + ) { + require(!birthDate.isAfter(LocalDate.now())) { + "생년월일은 미래 날짜일 수 없습니다" + } + } + + private fun validateEssentialDomainRules( + birthDate: LocalDate, + codeImages: List, + ) { + require(!birthDate.isAfter(LocalDate.now())) { + "생년월일은 미래 날짜일 수 없습니다" + } + require(codeImages.isNotEmpty()) { "코드 이미지가 필요합니다" } + } + + private fun validatePersonalityDomainRules( + personalities: List, + height: Int?, + mbti: String?, + interests: List + ) { + require(essentialCompleted) { "기본 프로필을 먼저 완성해야 합니다" } + require(personalities.isNotEmpty()) { "성격을 최소 1개는 선택해야 합니다" } + + height?.let { + require(it in 120..220) { "키는 120-220cm 사이여야 합니다" } + } + + mbti?.let { + require(it.length == 4 && it.all { char -> char.isLetter() }) { + "MBTI는 4자리 영문이어야 합니다" + } + } + + require(interests.isNotEmpty()) { "관심사가 필요합니다" } + } + + private fun validateHiddenInfoDomainRules() { + require(isPublicProfileComplete()) { "공개 프로필을 먼저 완성해야 합니다" } + } + + private fun validateHiddenDomainRules(faceImages: List) { + require(isPublicProfileComplete()) { "공개 프로필을 먼저 완성해야 합니다" } + require(faceImages.isNotEmpty()) { "얼굴 이미지가 필요합니다" } + } + + + // ===== get...OrThrow 메서드들 ===== + + // Essential Profile + fun getCodeNameOrThrow(): String = codeName ?: throw MemberException(HttpStatus.BAD_REQUEST, "닉네임이 설정되지 않았습니다.") + fun getBirthDateOrThrow(): LocalDate = birthDate ?: throw MemberException(HttpStatus.BAD_REQUEST, "생년월일이 설정되지 않았습니다.") + fun getBigCityOrThrow(): String = bigCity ?: throw MemberException(HttpStatus.BAD_REQUEST, "시/도가 설정되지 않았습니다.") + fun getSmallCityOrThrow(): String = smallCity ?: throw MemberException(HttpStatus.BAD_REQUEST, "시/군/구가 설정되지 않았습니다.") + fun getJobOrThrow(): String = job ?: throw MemberException(HttpStatus.BAD_REQUEST, "직업이 설정되지 않았습니다.") + // Personality Profile + fun getHairLengthOrThrow(): String = hairLength ?: throw MemberException(HttpStatus.BAD_REQUEST, "헤어 길이가 설정되지 않았습니다.") + fun getBodyTypeOrThrow(): String = bodyType ?: throw MemberException(HttpStatus.BAD_REQUEST, "체형이 설정되지 않았습니다.") + fun getHeightOrThrow(): Int = height ?: throw MemberException(HttpStatus.BAD_REQUEST, "키가 설정되지 않았습니다.") + fun getStyleOrThrow(): String = style ?: throw MemberException(HttpStatus.BAD_REQUEST, "스타일이 설정되지 않았습니다.") + fun getMbtiOrThrow(): String = mbti ?: throw MemberException(HttpStatus.BAD_REQUEST, "MBTI가 설정되지 않았습니다.") + fun getAlcoholOrThrow(): String = alcohol ?: throw MemberException(HttpStatus.BAD_REQUEST, "음주 스타일이 설정되지 않았습니다.") + fun getSmokeOrThrow(): String = smoke ?: throw MemberException(HttpStatus.BAD_REQUEST, "흡연 스타일이 설정되지 않았습니다.") + fun getPersonalitiesOrThrow(): String = personalities ?: throw MemberException(HttpStatus.BAD_REQUEST, "성격이 설정되지 않았습니다.") + fun getRepresentativeQuestionOrThrow():Question = representativeQuestion ?: throw MemberException(HttpStatus.BAD_REQUEST, "대표 질문이 설정되지 않았습니다.") + fun getRepresentativeAnswerOrThrow(): String = representativeAnswer ?: throw MemberException(HttpStatus.BAD_REQUEST, "대표 답변이 설정되지 않았습니다.") + + // Hidden Profile + fun getLoveLanguageOrThrow(): String = loveLanguage ?: throw MemberException(HttpStatus.BAD_REQUEST, "사랑의 언어가 설정되지 않았습니다.") + fun getAffectionStyleOrThrow(): String = affectionStyle ?: throw MemberException(HttpStatus.BAD_REQUEST, "애정 표현 스타일이 설정되지 않았습니다.") + fun getContactStyleOrThrow(): String = contactStyle ?: throw MemberException(HttpStatus.BAD_REQUEST, "연락 스타일이 설정되지 않았습니다.") + fun getDateStyleOrThrow(): String = dateStyle ?: throw MemberException(HttpStatus.BAD_REQUEST, "데이트 스타일이 설정되지 않았습니다.") + fun getConflictResolutionStyleOrThrow(): String = conflictResolutionStyle ?: throw MemberException(HttpStatus.BAD_REQUEST, "갈등 해결 스타일이 설정되지 않았습니다.") + fun getRelationshipValuesOrThrow(): String = relationshipValues ?: throw MemberException(HttpStatus.BAD_REQUEST, "연애 가치관이 설정되지 않았습니다.") + + // 기타 + fun getIntroduceOrThrow(): String = introduce ?: throw MemberException(HttpStatus.BAD_REQUEST, "자기소개가 설정되지 않았습니다.") + fun getMemberOrThrow(): Member = member ?: throw MemberException(HttpStatus.BAD_REQUEST, "회원 정보가 설정되지 않았습니다.") + + // ===== 거절된 이미지 교체 메서드 ===== + + /** + * 거절된 코드 이미지를 새 이미지로 전체 교체 + */ + fun replaceAllCodeImages(newCodeImages: List) { + require(newCodeImages.isNotEmpty()) { "코드 이미지가 필요합니다" } + + // 1. 기존 Entity 전체 삭제 + this.codeImages.clear() + + // 2. 새 이미지로 교체 + newCodeImages.forEachIndexed { index, url -> + this.codeImages.add( + CodeImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + // 3. String 필드도 업데이트 (하위 호환성) + this.codeImage = serializeList(newCodeImages) + this.updatedAt = LocalDateTime.now() + } + + /** + * 코드 이미지 String 필드만 업데이트 (엔티티는 Repository에서 직접 관리) + */ + fun updateCodeImageUrls(urls: List) { + require(urls.isNotEmpty()) { "코드 이미지가 필요합니다" } + this.codeImage = serializeList(urls) + this.updatedAt = LocalDateTime.now() + } + + /** + * 얼굴 이미지 String 필드만 업데이트 (엔티티는 Repository에서 직접 관리) + */ + fun updateFaceImageUrls(urls: List) { + require(urls.size == 2) { "얼굴 이미지는 정확히 2개가 필요합니다" } + this.faceImage = serializeList(urls) + this.updatedAt = LocalDateTime.now() + } + + /** + * 거절된 얼굴 이미지를 새 이미지로 전체 교체 + */ + fun replaceAllFaceImages(newFaceImages: List) { + require(newFaceImages.isNotEmpty()) { "얼굴 이미지가 필요합니다" } + + // 1. 기존 Entity 전체 삭제 + this.faceImages.clear() + + // 2. 새 이미지로 교체 + newFaceImages.forEachIndexed { index, url -> + this.faceImages.add( + FaceImage( + profile = this, + url = url, + orders = index, + isApproved = true + ) + ) + } + + // 3. String 필드도 업데이트 (하위 호환성) + this.faceImage = serializeList(newFaceImages) + this.updatedAt = LocalDateTime.now() + } + +} diff --git a/src/main/kotlin/codel/member/domain/RejectReason.kt b/src/main/kotlin/codel/member/domain/RejectReason.kt new file mode 100644 index 00000000..d6ed0167 --- /dev/null +++ b/src/main/kotlin/codel/member/domain/RejectReason.kt @@ -0,0 +1,13 @@ +package codel.member.domain + +import jakarta.persistence.* + +@Entity +class RejectReason( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + @OneToOne + var member: Member, + var reason: String, +) diff --git a/src/main/kotlin/codel/member/domain/RejectionHistory.kt b/src/main/kotlin/codel/member/domain/RejectionHistory.kt new file mode 100644 index 00000000..b19ce34e --- /dev/null +++ b/src/main/kotlin/codel/member/domain/RejectionHistory.kt @@ -0,0 +1,66 @@ +package codel.member.domain + +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* +import java.time.LocalDateTime + +/** + * 프로필 심사 거절 이력 엔티티 + * - 회원의 프로필이 거절될 때마다 차수를 증가시키며 이력을 보관 + * - S3 이미지 URL을 보존하여 과거 거절 내역 조회 가능 + */ +@Entity +@Table(name = "rejection_histories") +class RejectionHistory( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + val member: Member, + + @Column(name = "rejection_round", nullable = false) + val rejectionRound: Int, + + @Enumerated(EnumType.STRING) + @Column(name = "image_type", nullable = false, length = 50) + val imageType: ImageType, + + @Column(name = "image_id", nullable = false) + val imageId: Long, + + @Column(name = "image_url", nullable = false, length = 500) + val imageUrl: String, + + @Column(name = "image_order", nullable = false) + val imageOrder: Int, + + @Column(nullable = false, length = 1000) + val reason: String, + + @Column(name = "rejected_at", nullable = false) + val rejectedAt: LocalDateTime +) : BaseTimeEntity() { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RejectionHistory) return false + if (id == 0L) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() + + override fun toString(): String { + return "RejectionHistory(id=$id, memberId=${member.id}, round=$rejectionRound, imageType=$imageType)" + } +} + +/** + * 거절된 이미지의 타입 + */ +enum class ImageType { + FACE_IMAGE, + CODE_IMAGE +} diff --git a/src/main/kotlin/codel/member/exception/MemberException.kt b/src/main/kotlin/codel/member/exception/MemberException.kt new file mode 100644 index 00000000..6adee43b --- /dev/null +++ b/src/main/kotlin/codel/member/exception/MemberException.kt @@ -0,0 +1,9 @@ +package codel.member.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class MemberException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/member/infrastructure/CodeImageRepository.kt b/src/main/kotlin/codel/member/infrastructure/CodeImageRepository.kt new file mode 100644 index 00000000..ad146df7 --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/CodeImageRepository.kt @@ -0,0 +1,10 @@ +package codel.member.infrastructure + +import codel.member.domain.CodeImage +import org.springframework.data.jpa.repository.JpaRepository + +interface CodeImageRepository : JpaRepository { + fun findByProfileIdOrderByOrdersAsc(profileId: Long): List + fun findByProfileIdAndIsApprovedFalse(profileId: Long): List + fun deleteByProfileId(profileId: Long) +} diff --git a/src/main/kotlin/codel/member/infrastructure/FaceImageRepository.kt b/src/main/kotlin/codel/member/infrastructure/FaceImageRepository.kt new file mode 100644 index 00000000..b5f65f0e --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/FaceImageRepository.kt @@ -0,0 +1,10 @@ +package codel.member.infrastructure + +import codel.member.domain.FaceImage +import org.springframework.data.jpa.repository.JpaRepository + +interface FaceImageRepository : JpaRepository { + fun findByProfileIdOrderByOrdersAsc(profileId: Long): List + fun findByProfileIdAndIsApprovedFalse(profileId: Long): List + fun deleteByProfileId(profileId: Long) +} diff --git a/src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt b/src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt new file mode 100644 index 00000000..f1cc9def --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/MemberJpaRepository.kt @@ -0,0 +1,253 @@ +package codel.member.infrastructure + +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface MemberJpaRepository : JpaRepository { + fun existsByOauthTypeAndOauthId( + oauthType: OauthType, + oauthId: String, + ): Boolean + + fun findByOauthTypeAndOauthId( + oauthType: OauthType, + oauthId: String, + ): Member + + fun findByMemberStatus(memberStatus: MemberStatus): List + + + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m + WHERE m.id <> :excludeId + AND m.memberStatus = codel.member.domain.MemberStatus.DONE + ORDER BY function('RAND', :seed) + """) + fun findRandomMembersStatusDone( + @Param("excludeId") excludeId: Long, + @Param("seed") seed: Long, + ): List + + @Query( + value = "SELECT * FROM member WHERE id <> :excludeId AND member_status = 'DONE'", + nativeQuery = true, + ) + fun findMembersWithStatusDoneExcludeMe( + @Param("excludeId") excludeId: Long + ): List + + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m + WHERE m.id <> :excludeId + AND m.memberStatus = 'DONE' + ORDER BY function('RAND', :seed) + """) + fun findRandomMembersStatusDoneWithProfile( + @Param("excludeId") excludeId: Long, + @Param("seed") seed: Long, + ): List + + @Query( + """ + SELECT m FROM Member m JOIN FETCH m.profile p + WHERE (:status IS NULL OR m.memberStatus = :status) + AND ( + :keyword IS NULL OR :keyword = '' + OR LOWER(m.email) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + ORDER BY m.id DESC + """ + ) + fun findMembersWithFilter( + @Param("keyword") keyword: String?, + @Param("status") status: MemberStatus?, + pageable: Pageable + ): Page + + @Query( + """ + SELECT m FROM Member m JOIN FETCH m.profile p + WHERE (:status IS NULL OR m.memberStatus = :status) + AND ( + :keyword IS NULL OR :keyword = '' + OR LOWER(m.email) LIKE LOWER(CONCAT('%', :keyword, '%')) + OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%')) + ) + """ + ) + fun findMembersWithFilterAdvanced( + @Param("keyword") keyword: String?, + @Param("status") status: MemberStatus?, + pageable: Pageable + ): Page + + fun countByMemberStatus(status: MemberStatus): Long + + + @Query("SELECT m FROM Member m JOIN FETCH m.profile WHERE m.id = :memberId") + fun findByMemberId(memberId: Long) : Member? + + + @EntityGraph(attributePaths = ["profile", "profile.codeImages"]) + @Query("select m from Member m where m.id = :memberId") + fun findByMemberIdWithProfileAndCodeImages(memberId: Long) : Member? + + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query("select m from Member m where m.id = :id") + fun findMemberWithProfileAndQuestion(@Param("id") id: Long): Member? + + // ========== 통계용 쿼리 ========== + + @Query(""" + SELECT CAST(m.createdAt AS date) as date, COUNT(m) as count + FROM Member m + WHERE m.createdAt >= :startDate + GROUP BY CAST(m.createdAt AS date) + ORDER BY CAST(m.createdAt AS date) DESC + """) + fun getDailySignupStats(@Param("startDate") startDate: LocalDateTime): List> + + @Query(""" + SELECT m.memberStatus, COUNT(m) + FROM Member m + GROUP BY m.memberStatus + """) + fun getMemberStatusStats(): List> + + @Query(""" + SELECT YEAR(m.createdAt) as year, + MONTH(m.createdAt) as month, + COUNT(m) as count + FROM Member m + WHERE m.createdAt >= :startDate + GROUP BY YEAR(m.createdAt), MONTH(m.createdAt) + ORDER BY YEAR(m.createdAt) DESC, MONTH(m.createdAt) DESC + """) + fun getMonthlySignupStats(@Param("startDate") startDate: LocalDateTime): List> + + @Query(""" + SELECT COUNT(m) + FROM Member m + WHERE YEAR(m.createdAt) = YEAR(CURRENT_DATE) + AND MONTH(m.createdAt) = MONTH(CURRENT_DATE) + AND DAY(m.createdAt) = DAY(CURRENT_DATE) + """) + fun getTodaySignupCount(): Long + + @Query(""" + SELECT COUNT(m) + FROM Member m + WHERE m.createdAt >= :startDate + """) + fun getRecentSignupCount(@Param("startDate") startDate: LocalDateTime): Long + + // ========== 추천 시스템용 버킷 쿼리 ========== + + /** + * B1 버킷: 동일한 mainRegion과 subRegion을 가진 사용자들 조회 + * 타이브레이커: 최근 접속순 → 가입일 최신순 + */ + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m JOIN m.profile p + WHERE m.memberStatus = 'DONE' + AND p.bigCity = :mainRegion + AND p.smallCity = :subRegion + AND m.id NOT IN :excludeIds + ORDER BY m.updatedAt DESC, m.createdAt DESC + """) + fun findByMainRegionAndSubRegionAndStatusDone( + @Param("mainRegion") mainRegion: String, + @Param("subRegion") subRegion: String, + @Param("excludeIds") excludeIds: Set + ): List + + /** + * B2 버킷: 동일한 mainRegion이지만 다른 subRegion을 가진 사용자들 조회 + * 타이브레이커: 최근 접속순 → 가입일 최신순 + */ + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m JOIN m.profile p + WHERE m.memberStatus = 'DONE' + AND p.bigCity = :mainRegion + AND p.smallCity != :excludeSubRegion + AND m.id NOT IN :excludeIds + ORDER BY m.updatedAt DESC, m.createdAt DESC + """) + fun findByMainRegionAndNotSubRegionAndStatusDone( + @Param("mainRegion") mainRegion: String, + @Param("excludeSubRegion") excludeSubRegion: String, + @Param("excludeIds") excludeIds: Set + ): List + + /** + * B3 버킷: 특정 mainRegion 목록에 속하는 사용자들 조회 (인접 지역) + * 타이브레이커: 최근 접속순 → 가입일 최신순 + */ + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m JOIN m.profile p + WHERE m.memberStatus = 'DONE' + AND p.bigCity IN :adjacentRegions + AND m.id NOT IN :excludeIds + ORDER BY m.updatedAt DESC, m.createdAt DESC + """) + fun findByAdjacentMainRegionsAndStatusDone( + @Param("adjacentRegions") adjacentRegions: List, + @Param("excludeIds") excludeIds: Set + ): List + + /** + * B4 버킷: 전국 범위에서 랜덤하게 사용자들 조회 (최후 보충) + * 랜덤 정렬로 공정성 보장 + */ + @EntityGraph(attributePaths = ["profile", "profile.representativeQuestion"]) + @Query(""" + SELECT m + FROM Member m JOIN m.profile p + WHERE m.memberStatus = 'DONE' + AND m.id NOT IN :excludeIds + ORDER BY function('RAND') + """) + fun findByStatusDoneExcludingIds( + @Param("excludeIds") excludeIds: Set + ): List + + + @Query(""" + SELECT DISTINCT m FROM Member m + JOIN FETCH m.profile p + LEFT JOIN FETCH p.representativeQuestion + WHERE m.id IN :ids + """) + fun findAllByIdsWithProfileAndQuestion(@Param("ids") ids: List): List + /** + * 관리자용: 프로필만 Fetch Join으로 조회 + */ + @Query(""" + SELECT m + FROM Member m + LEFT JOIN FETCH m.profile p + WHERE m.id = :memberId + """) + fun findMemberWithProfile(@Param("memberId") memberId: Long): Member? +} diff --git a/src/main/kotlin/codel/member/infrastructure/ProfileJpaRepository.kt b/src/main/kotlin/codel/member/infrastructure/ProfileJpaRepository.kt new file mode 100644 index 00000000..116dcc82 --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/ProfileJpaRepository.kt @@ -0,0 +1,10 @@ +package codel.member.infrastructure + +import codel.member.domain.Member +import codel.member.domain.Profile +import org.springframework.data.jpa.repository.JpaRepository + +interface ProfileJpaRepository : JpaRepository { + fun findByMemberId(member: Member): Profile? +} + diff --git a/src/main/kotlin/codel/member/infrastructure/RejectReasonJpaRepository.kt b/src/main/kotlin/codel/member/infrastructure/RejectReasonJpaRepository.kt new file mode 100644 index 00000000..bbf3a6f0 --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/RejectReasonJpaRepository.kt @@ -0,0 +1,11 @@ +package codel.member.infrastructure + +import codel.member.domain.Member +import codel.member.domain.RejectReason +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RejectReasonJpaRepository : JpaRepository { + fun findByMember(member: Member): RejectReason? +} diff --git a/src/main/kotlin/codel/member/infrastructure/RejectionHistoryRepository.kt b/src/main/kotlin/codel/member/infrastructure/RejectionHistoryRepository.kt new file mode 100644 index 00000000..c771c3ac --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/RejectionHistoryRepository.kt @@ -0,0 +1,29 @@ +package codel.member.infrastructure + +import codel.member.domain.RejectionHistory +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface RejectionHistoryRepository : JpaRepository { + + /** + * 특정 회원의 모든 거절 이력 조회 (최신순) + */ + fun findByMemberIdOrderByRejectedAtDesc(memberId: Long): List + + /** + * 특정 회원의 특정 차수 거절 이력 조회 + */ + fun findByMemberIdAndRejectionRound(memberId: Long, rejectionRound: Int): List + + /** + * 특정 회원의 최대 거절 차수 조회 + */ + @Query("SELECT COALESCE(MAX(rh.rejectionRound), 0) FROM RejectionHistory rh WHERE rh.member.id = :memberId") + fun findMaxRejectionRoundByMemberId(memberId: Long): Int + + /** + * 특정 회원의 거절 이력 개수 조회 + */ + fun countByMemberId(memberId: Long): Long +} diff --git a/src/main/kotlin/codel/member/infrastructure/S3Uploader.kt b/src/main/kotlin/codel/member/infrastructure/S3Uploader.kt new file mode 100644 index 00000000..4813b72b --- /dev/null +++ b/src/main/kotlin/codel/member/infrastructure/S3Uploader.kt @@ -0,0 +1,32 @@ +package codel.member.infrastructure + +import codel.member.domain.ImageUploader +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.multipart.MultipartFile +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.util.* + +@Component +class S3Uploader( + private val s3Client: S3Client, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, +) : ImageUploader { + override fun uploadFile(file: MultipartFile): String { + val fileName = "images/${UUID.randomUUID()}-${file.originalFilename}" + + val putObjectRequest = + PutObjectRequest + .builder() + .bucket(bucket) + .key(fileName) + .contentType(file.contentType) + .build() + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(file.bytes)) + + return "https://$bucket.s3.amazonaws.com/$fileName" + } +} diff --git a/src/main/kotlin/codel/member/presentation/MemberController.kt b/src/main/kotlin/codel/member/presentation/MemberController.kt new file mode 100644 index 00000000..c433c9c3 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/MemberController.kt @@ -0,0 +1,170 @@ +package codel.member.presentation + +import codel.auth.business.AuthService +import codel.config.argumentresolver.LoginMember +import codel.member.business.MemberService +import codel.member.domain.Member +import codel.member.presentation.request.MemberLoginRequest +import codel.member.presentation.request.WithdrawnRequest +import codel.member.presentation.request.UpdateRepresentativeQuestionRequest +import codel.member.presentation.response.* +import codel.member.presentation.swagger.MemberControllerSwagger +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.data.domain.Page +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@RestController +class MemberController( + private val memberService: MemberService, + private val authService: AuthService, + private val asyncNotificationService: IAsyncNotificationService, + private val messagingTemplate: org.springframework.messaging.simp.SimpMessagingTemplate, +) : MemberControllerSwagger { + @PostMapping("/v1/member/login") + override fun loginMember( + @RequestBody request: MemberLoginRequest, + ): ResponseEntity { + val member = memberService.loginMember(request.toMember()) + val token = authService.provideToken(member) + return ResponseEntity + .ok() + .header("Authorization", "Bearer $token") + .body(MemberLoginResponse(member.getIdOrThrow(), member.memberStatus)) + } + + @PostMapping("/v1/member/fcmtoken") + override fun saveFcmToken( + @LoginMember member: Member, + @RequestBody fcmToken: String, + ): ResponseEntity { + memberService.saveFcmToken(member, fcmToken) + return ResponseEntity.ok().build() + } + + @GetMapping("/v1/member/me") + override fun findMyProfile( + @LoginMember member: Member, + ): ResponseEntity { + val findMyProfile = memberService.findMyProfile(member) + return ResponseEntity.ok(findMyProfile) + } + + @GetMapping("/v1/member/recommend") + @Deprecated("Use /api/v1/recommendations/daily-code-matching or /api/v1/recommendations/legacy/recommend instead") + override fun recommendMembers( + @LoginMember member: Member, + ): ResponseEntity { + val members = memberService.recommendMembers(member) + return ResponseEntity.ok(MemberRecommendResponse.from(members)) + } + + @GetMapping("/v1/member/all") + @Deprecated("Use /api/v1/recommendations/random or /api/v1/recommendations/legacy/all instead") + override fun getRecommendMemberAtTenHourCycle( + @LoginMember member: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "8") size: Int, + ): ResponseEntity> { + val memberPage = memberService.getRandomMembers(member, page, size) + + return ResponseEntity.ok( + memberPage.map { member -> + FullProfileResponse.createOpen(member) + }, + ) + } + + @GetMapping("/v1/members/{id}") + override fun getMemberProfileDetail( + @LoginMember me: Member, + @PathVariable id: Long, + ): ResponseEntity { + val memberProfileDetail = memberService.findMemberProfile(me, id) + + return ResponseEntity.ok(memberProfileDetail) + } + + @PostMapping("/v1/member/me") + override fun withdrawMember( + @LoginMember member: Member, + @RequestBody request : WithdrawnRequest + ): ResponseEntity { + // 1. 회원 탈퇴 처리 (Signal, ChatRoom, Member 상태 변경) + val chatNotifications = memberService.withdrawMember(member, request.reason) + + // 2. WebSocket으로 채팅방 종료 알림 발송 + chatNotifications.forEach { notification -> + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${notification.partner.id}", + notification.partnerChatRoomResponse + ) + + // 채팅방 구독자들에게 시스템 메시지 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/${notification.partnerChatRoomResponse.chatRoomId}", + notification.chatResponse + ) + // 상대방에게 채팅방 종료 알림 전송 + } + + // 3. 비동기로 Discord 알림 발송 + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "${member.getProfileOrThrow().getCodeNameOrThrow()}님이 탈퇴하였습니다.", + body = """ + 👩‍💻 탈퇴 회원: ${member.getProfileOrThrow().getCodeNameOrThrow()} + 🗓 탈퇴 시각: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))} + 📊 탈퇴 사유: ${request.reason.ifBlank { "미입력" }} + 💬 종료된 채팅방: ${chatNotifications.size}개 + """.trimIndent(), + ), + ) + + return ResponseEntity.noContent().build() + } + + // ========== 프로필 수정 ========== + + /** + * 코드 이미지 수정 + * - Multipart 파일로 새 이미지를 업로드 + * - existingIds로 유지할 이미지 지정 가능 + * - 상태가 PENDING으로 변경되어 재심사 진행 (필요 시) + */ + @PutMapping("/v1/member/me/profile/code-images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun updateCodeImages( + @LoginMember member: Member, + @RequestParam(value = "codeImages", required = false) codeImages: List?, + @RequestParam(value = "existingIds", required = false) existingIds: List? + ): ResponseEntity { + val response = memberService.updateCodeImages(member, codeImages, existingIds) + return ResponseEntity.ok(response) + } + + /** + * 대표 질문 및 답변 수정 + * - 기존 질문 ID와 새로운 답변으로 수정 + */ + @PutMapping("/v1/member/me/profile/representative-question") + override fun updateRepresentativeQuestion( + @LoginMember member: Member, + @RequestBody request: UpdateRepresentativeQuestionRequest + ): ResponseEntity { + val response = memberService.updateRepresentativeQuestion( + member, + request.representativeQuestionId, + request.representativeAnswer + ) + return ResponseEntity.ok(response) + } +} diff --git a/src/main/kotlin/codel/member/presentation/ProfileReviewController.kt b/src/main/kotlin/codel/member/presentation/ProfileReviewController.kt new file mode 100644 index 00000000..33255b06 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/ProfileReviewController.kt @@ -0,0 +1,112 @@ +package codel.member.presentation + +import codel.config.argumentresolver.LoginMember +import codel.member.business.ProfileReviewService +import codel.member.domain.Member +import codel.member.presentation.response.ProfileRejectionInfoResponse +import codel.member.presentation.response.ProfileImagesResponse +import codel.member.presentation.response.ReplaceImagesResponse +import codel.member.presentation.response.ResubmitProfileResponse +import codel.member.presentation.swagger.ProfileReviewControllerSwagger +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/v1/profile/review") +class ProfileReviewController( + private val profileReviewService: ProfileReviewService, + private val asyncNotificationService: IAsyncNotificationService +) : ProfileReviewControllerSwagger { + + + @GetMapping("/rejection-info") + override fun getRejectionInfo( + @LoginMember member: Member + ): ResponseEntity { + val rejectionInfo = profileReviewService.getRejectionInfo(member) + return ResponseEntity.ok(rejectionInfo) + } + + + @GetMapping("/images") + override fun getProfileImages( + @LoginMember member: Member + ): ResponseEntity { + val images = profileReviewService.getProfileImages(member) + return ResponseEntity.ok(images) + } + + + @PutMapping("/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun replaceImages( + @LoginMember member: Member, + faceImages: List?, + codeImages: List?, + existingFaceImageIds: List?, + existingCodeImageIds: List? + ): ResponseEntity { + val response = profileReviewService.replaceImages( + member, + faceImages, + codeImages, + existingFaceImageIds, + existingCodeImageIds + ) + + // 비동기로 알림 전송 + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "${member.getProfileOrThrow().getCodeNameOrThrow()}님이 재심사를 요청하였습니다.", + body = "code:L 프로필 재심사 요청이 왔습니다.", + ), + ) + return ResponseEntity.ok(response) + } + + @PostMapping("/resubmit", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun resubmitProfile( + @LoginMember member: Member, + @RequestPart(value = "faceImages", required = false) faceImages: List?, + @RequestPart(value = "codeImages", required = false) codeImages: List?, + @RequestParam(value = "existingFaceImageIds", required = false) existingFaceImageIds: List?, + @RequestParam(value = "existingCodeImageIds", required = false) existingCodeImageIds: List?, + @RequestParam(value = "standardImageId") standardImageId: Long, + @RequestPart(value = "verificationImage") verificationImage: MultipartFile + ): ResponseEntity { + val response = profileReviewService.resubmitProfileForReview( + member = member, + faceImages = faceImages, + codeImages = codeImages, + existingFaceImageIds = existingFaceImageIds, + existingCodeImageIds = existingCodeImageIds, + standardImageId = standardImageId, + verificationImage = verificationImage + ) + + // 비동기로 알림 전송 + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "${member.getProfileOrThrow().getCodeNameOrThrow()}님이 재심사를 요청하였습니다.", + body = "code:L 프로필 재심사 요청이 왔습니다.", + ), + ) + return ResponseEntity.ok(response) + } +} diff --git a/src/main/kotlin/codel/member/presentation/SignupController.kt b/src/main/kotlin/codel/member/presentation/SignupController.kt new file mode 100644 index 00000000..0fe41101 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/SignupController.kt @@ -0,0 +1,113 @@ +package codel.member.presentation + +import codel.config.argumentresolver.LoginMember +import codel.member.business.MemberService +import codel.member.business.SignupService +import codel.member.business.signup.SignupStrategyResolver +import codel.member.domain.Member +import codel.member.presentation.request.EssentialProfileRequest +import codel.member.presentation.request.HiddenProfileRequest +import codel.member.presentation.request.PersonalityProfileRequest +import codel.member.presentation.response.SignUpStatusResponse +import codel.member.presentation.swagger.SignupControllerSwagger +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import codel.verification.presentation.response.VerificationImageResponse +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/v1/signup") +class SignupController( + private val memberService: MemberService, + private val signupService: SignupService, + private val signupStrategyResolver: SignupStrategyResolver, + private val asyncNotificationService: IAsyncNotificationService +) : SignupControllerSwagger { + + @GetMapping("/status") + override fun getSignupStatus( + @LoginMember member: Member + ): ResponseEntity { + val currentMember = memberService.findMember(member.getIdOrThrow()) + return ResponseEntity.ok(SignUpStatusResponse.from(currentMember)) + } + + @PostMapping("/phone/verify") + override fun completePhoneVerification( + @LoginMember member: Member, + ): ResponseEntity { + signupService.completePhoneVerification(member) + return ResponseEntity.ok().build() + } + + @PostMapping("/open/profile") + override fun registerEssentialProfile( + @LoginMember member: Member, + @RequestBody request: EssentialProfileRequest + ): ResponseEntity { + signupService.registerEssentialProfile(member, request) + return ResponseEntity.ok().build() + } + + @PostMapping("/open/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun registerEssentialImages( + @LoginMember member: Member, + @RequestPart images: List + ): ResponseEntity { + signupService.registerEssentialImages(member, images) + return ResponseEntity.ok().build() + } + + @PostMapping("/open/personality") + override fun registerPersonalityProfile( + @LoginMember member: Member, + @RequestBody request: PersonalityProfileRequest + ): ResponseEntity { + signupService.registerPersonalityProfile(member, request) + return ResponseEntity.ok().build() + } + + @PostMapping("/hidden/profile") + override fun registerHiddenProfile( + @LoginMember member: Member, + @RequestBody request: HiddenProfileRequest + ): ResponseEntity { + signupService.registerHiddenProfile(member, request) + return ResponseEntity.ok().build() + } + + @PostMapping("/hidden/images", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun registerHiddenImages( + @LoginMember member: Member, + @RequestPart images: List, + @RequestHeader("X-App-Version", required = false) appVersion: String? + ): ResponseEntity { + // 앱 버전에 따라 적절한 전략을 선택하여 처리 + val strategy = signupStrategyResolver.resolveStrategy(appVersion) + return strategy.handleHiddenImages(member, images) + } + + @PostMapping("/verification/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + override fun submitVerificationImage( + @LoginMember member: Member, + @RequestParam standardImageId: Long, + @RequestPart userImage: MultipartFile + ): ResponseEntity { + val response = signupService.submitVerificationImage(member, standardImageId, userImage) + // 비동기로 알림 전송 + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getIdOrThrow().toString(), + title = "${member.getProfileOrThrow().getCodeNameOrThrow()}님이 심사를 요청하였습니다.", + body = "code:L 프로필 심사 요청이 왔습니다.", + ), + ) + return ResponseEntity.ok(response) + } +} diff --git a/src/main/kotlin/codel/member/presentation/request/CodeImageSavedRequest.kt b/src/main/kotlin/codel/member/presentation/request/CodeImageSavedRequest.kt new file mode 100644 index 00000000..a4a98b86 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/CodeImageSavedRequest.kt @@ -0,0 +1,7 @@ +package codel.member.presentation.request + +import org.springframework.web.multipart.MultipartFile + +data class CodeImageSavedRequest( + val imageFiles: List, +) diff --git a/src/main/kotlin/codel/member/presentation/request/EssentialProfileRequest.kt b/src/main/kotlin/codel/member/presentation/request/EssentialProfileRequest.kt new file mode 100644 index 00000000..4e20ff30 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/EssentialProfileRequest.kt @@ -0,0 +1,39 @@ +package codel.member.presentation.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import java.time.LocalDate +import java.time.Period + +data class EssentialProfileRequest( + @field:NotBlank(message = "닉네임을 입력해주세요") + val codeName: String, + + @field:NotBlank(message = "생년월일을 입력해주세요") + @field:Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "생년월일 형식: YYYY-MM-DD") + val birthDate: String, + + @field:NotBlank(message = "시/도를 선택해주세요") + val bigCity: String, + + @field:NotBlank(message = "시/군/구를 선택해주세요") + val smallCity: String, + + @field:NotBlank(message = "직업을 선택해주세요") + val jobCategory: String, +) { + fun validateSelf() { + // 나이 검증 + val birthDate = try { + LocalDate.parse(this.birthDate) + } catch (_: Exception) { + throw IllegalArgumentException("생년월일 형식이 올바르지 않습니다") + } + + val age = Period.between(birthDate, LocalDate.now()).years + require(age in 19..99) { + "나이는 19-99세 사이여야 합니다 (현재: ${age}세)" + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/request/HiddenProfileRequest.kt b/src/main/kotlin/codel/member/presentation/request/HiddenProfileRequest.kt new file mode 100644 index 00000000..1a4dbd48 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/HiddenProfileRequest.kt @@ -0,0 +1,23 @@ +package codel.member.presentation.request + +import jakarta.validation.constraints.NotBlank + +data class HiddenProfileRequest( + @field:NotBlank(message = "사랑의 언어를 선택해주세요") + val loveLanguage: String, + + @field:NotBlank(message = "애정 표현 스타일을 선택해주세요") + val affectionStyle: String, + + @field:NotBlank(message = "연락 스타일을 선택해주세요") + val contactStyle: String, + + @field:NotBlank(message = "데이트 스타일을 선택해주세요") + val dateStyle: String, + + @field:NotBlank(message = "갈등 해결 스타일을 선택해주세요") + val conflictResolutionStyle: String, + + @field:NotBlank(message = "연애 가치관을 선택해주세요") + val relationshipValues: String +) diff --git a/src/main/kotlin/codel/member/presentation/request/MemberLoginRequest.kt b/src/main/kotlin/codel/member/presentation/request/MemberLoginRequest.kt new file mode 100644 index 00000000..f7748cc9 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/MemberLoginRequest.kt @@ -0,0 +1,19 @@ +package codel.member.presentation.request + +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType + +data class MemberLoginRequest( + val oauthType: OauthType, + val oauthId: String, + val email: String?, +) { + fun toMember(): Member = + Member( + oauthType = this.oauthType, + oauthId = this.oauthId, + email = this.email ?: "", + memberStatus = MemberStatus.SIGNUP, + ) +} diff --git a/src/main/kotlin/codel/member/presentation/request/PersonalityProfileRequest.kt b/src/main/kotlin/codel/member/presentation/request/PersonalityProfileRequest.kt new file mode 100644 index 00000000..ddbc0f0e --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/PersonalityProfileRequest.kt @@ -0,0 +1,58 @@ +package codel.member.presentation.request + +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import kotlin.compareTo + +data class PersonalityProfileRequest( + val hairLength: String?, + val bodyType: String?, + + @field:Min(value = 120, message = "키는 120cm 이상이어야 합니다") + @field:Max(value = 220, message = "키는 220cm 이하여야 합니다") + val height: Int?, + + @field:Size(max = 5, message = "스타일은 최대 5개까지 선택 가능합니다") + val styles: List, + + @field:Pattern(regexp = "[A-Z]{4}", message = "MBTI는 4자리 대문자 영문이어야 합니다") + val mbti: String?, + + val drinkingStyle: String?, + val smokingStyle: String?, + + @field:Size(min = 1, max = 5, message = "성격은 1-5개 사이여야 합니다") + val personalities: List, + + + @field:Size(min = 1, max = 5, message = "관심사는 1-5개 사이여야 합니다") + val interests: List, + + val questionId: Long?, + + val answer: String? +) { + fun validateSelf() { + // 관심사 개별 검증 + interests.forEach { interest -> + require(interest.isNotBlank() && interest.length <= 20) { + "각 관심사는 1-20자 사이여야 합니다: '$interest'" + } + } + // 성격 개별 검증 + personalities.forEach { personality -> + require(personality.isNotBlank() && personality.length <= 15) { + "각 성격은 1-15자 사이여야 합니다: '$personality'" + } + } + + // 질문/답변 쌍 검증 + val hasQuestionId = questionId != null + val hasAnswer = !answer.isNullOrBlank() + require(hasQuestionId == hasAnswer) { + "대표 질문과 답변은 함께 입력해야 합니다" + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/request/UpdateRepresentativeQuestionRequest.kt b/src/main/kotlin/codel/member/presentation/request/UpdateRepresentativeQuestionRequest.kt new file mode 100644 index 00000000..d9111f5e --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/UpdateRepresentativeQuestionRequest.kt @@ -0,0 +1,14 @@ +package codel.member.presentation.request + +/** + * 대표 질문 및 답변 수정 요청 + */ +data class UpdateRepresentativeQuestionRequest( + val representativeQuestionId: Long, + val representativeAnswer: String +) { + init { + require(representativeAnswer.isNotBlank()) { "대표 답변은 비어있을 수 없습니다" } + require(representativeAnswer.length <= 1000) { "대표 답변은 1000자를 초과할 수 없습니다" } + } +} diff --git a/src/main/kotlin/codel/member/presentation/request/WithdrawnRequest.kt b/src/main/kotlin/codel/member/presentation/request/WithdrawnRequest.kt new file mode 100644 index 00000000..3498d6fd --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/request/WithdrawnRequest.kt @@ -0,0 +1,5 @@ +package codel.member.presentation.request + +data class WithdrawnRequest( + val reason : String +) \ No newline at end of file diff --git a/src/main/kotlin/codel/member/presentation/response/FullProfileResponse.kt b/src/main/kotlin/codel/member/presentation/response/FullProfileResponse.kt new file mode 100644 index 00000000..f8ba008d --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/FullProfileResponse.kt @@ -0,0 +1,44 @@ +package codel.member.presentation.response + +import codel.member.domain.AccessLevel +import codel.member.domain.Member +import codel.member.domain.OauthType + +/** + * 완전한 프로필 (오픈 + 숨김) + */ +data class FullProfileResponse( + val memberId: Long, + val openProfile: OpenProfileResponse, + val hiddenProfile: HiddenProfileResponse?, + val accessLevel: AccessLevel, + val isMyProfile: Boolean = false, + val oauthType : OauthType? +) { + companion object { + fun createOpen(member: Member): FullProfileResponse { + return FullProfileResponse( + memberId = member.getIdOrThrow(), + openProfile = OpenProfileResponse.from(member), + hiddenProfile = HiddenProfileResponse.from(member.getProfileOrThrow()), + accessLevel = AccessLevel.PUBLIC, + isMyProfile = false, + oauthType = null, + ) + } + + fun createFull(member: Member, isMyProfile: Boolean = false): FullProfileResponse { + val profile = member.getProfileOrThrow() + return FullProfileResponse( + memberId = member.getIdOrThrow(), + openProfile = OpenProfileResponse.from(member), + hiddenProfile = if (profile.hiddenCompleted) { + HiddenProfileResponse.from(profile) + } else null, + accessLevel = if (isMyProfile) AccessLevel.SELF else AccessLevel.CODE_EXCHANGED, + isMyProfile = isMyProfile, + oauthType = if(isMyProfile) member.oauthType else null, + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/HiddenProfileResponse.kt b/src/main/kotlin/codel/member/presentation/response/HiddenProfileResponse.kt new file mode 100644 index 00000000..b7d1b238 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/HiddenProfileResponse.kt @@ -0,0 +1,32 @@ +package codel.member.presentation.response + +import codel.member.domain.Profile + +/** + * Hidden Profile 정보 + */ +data class HiddenProfileResponse( + val loveLanguage: String, + val affectionStyle: String, + val contactStyle: String, + val dateStyle: String, + val conflictResolutionStyle: String, + val relationshipValues: String, + val faceImages: List +) { + companion object { + fun from(profile: Profile): HiddenProfileResponse { + require(profile.hiddenCompleted) { "Hidden Profile이 완성되지 않았습니다" } + + return HiddenProfileResponse( + loveLanguage = profile.getLoveLanguageOrThrow(), + affectionStyle = profile.getAffectionStyleOrThrow(), + contactStyle = profile.getContactStyleOrThrow(), + dateStyle = profile.getDateStyleOrThrow(), + conflictResolutionStyle = profile.getConflictResolutionStyleOrThrow(), + relationshipValues = profile.getRelationshipValuesOrThrow(), + faceImages = profile.getFaceImageOrThrow() + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/MemberLoginResponse.kt b/src/main/kotlin/codel/member/presentation/response/MemberLoginResponse.kt new file mode 100644 index 00000000..5f803fa4 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/MemberLoginResponse.kt @@ -0,0 +1,8 @@ +package codel.member.presentation.response + +import codel.member.domain.MemberStatus + +data class MemberLoginResponse( + val memberId : Long, + val memberStatus: MemberStatus, +) diff --git a/src/main/kotlin/codel/member/presentation/response/MemberProfileDetailResponse.kt b/src/main/kotlin/codel/member/presentation/response/MemberProfileDetailResponse.kt new file mode 100644 index 00000000..2c61ef95 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/MemberProfileDetailResponse.kt @@ -0,0 +1,45 @@ +package codel.member.presentation.response + +import codel.member.domain.Member +import codel.signal.domain.SignalStatus + +/** + * 프로필 상세 조회 (시그널 상태 포함) + */ +data class MemberProfileDetailResponse( + val profile: FullProfileResponse, + val signalStatus: SignalStatus, + val isUnlocked: Boolean = false +) { + companion object { + fun create( + member: Member, + signalStatus: SignalStatus, + isUnlocked: Boolean = false + ): MemberProfileDetailResponse { + val profileResponse = if (isUnlocked) { + FullProfileResponse.createFull(member) + } else { + FullProfileResponse.createOpen(member) + } + + return MemberProfileDetailResponse( + profile = profileResponse, + signalStatus = signalStatus, + isUnlocked = isUnlocked + ) + } + + fun createMyProfileResponse( + member: Member, + ): MemberProfileDetailResponse { + val profileResponse = FullProfileResponse.createFull(member, true) + + return MemberProfileDetailResponse( + profile = profileResponse, + signalStatus = SignalStatus.NONE, + isUnlocked = true + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/MemberRecommendResponses.kt b/src/main/kotlin/codel/member/presentation/response/MemberRecommendResponses.kt new file mode 100644 index 00000000..d333031f --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/MemberRecommendResponses.kt @@ -0,0 +1,19 @@ +package codel.member.presentation.response + +import codel.member.domain.Member + +/** + * 추천 멤버 목록 응답 + * 추천/파도타기에서는 히든 프로필 접근 불가능한 대상만 표시되므로 FullProfileResponse.createOpen 사용 + */ +data class MemberRecommendResponse( + val members: List +) { + companion object { + fun from(members: List): MemberRecommendResponse { + return MemberRecommendResponse( + members = members.map { FullProfileResponse.createOpen(it) } + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/OpenProfileResponse.kt b/src/main/kotlin/codel/member/presentation/response/OpenProfileResponse.kt new file mode 100644 index 00000000..0b655782 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/OpenProfileResponse.kt @@ -0,0 +1,60 @@ +package codel.member.presentation.response + +import codel.member.domain.Member + +/** + * 오픈 프로필 (Essential + Personality) - 코드 해제 전 공개되는 정보 + * 얼굴 사진을 제외한 모든 프로필 정보 + */ +data class OpenProfileResponse( + val codeName: String, + val age: Int, + val bigCity: String, + val smallCity: String, + // Essential Profile + val job: String, + val interests: List, + val codeImages: List, + val introduce: String?, + // Personality Profile + val hairLength: String, + val bodyType: String, + val height: Int, + val styles: List, + val mbti: String, + val drinkingStyle: String, + val smokingStyle: String, + val personalities: List, + val representativeQuestion: String, + val representativeAnswer: String +) { + companion object { + fun from(member: Member): OpenProfileResponse { + val profile = member.getProfileOrThrow() + require(profile.isPublicProfileComplete()) { "오픈 프로필이 완성되지 않았습니다" } + + return OpenProfileResponse( + codeName = profile.getCodeNameOrThrow(), + age = profile.getAge(), + bigCity = profile.getBigCityOrThrow(), + smallCity = profile.getSmallCityOrThrow(), + // Essential + job = profile.getJobOrThrow(), + interests = profile.getInterestsList(), + codeImages = profile.getCodeImageOrThrow(), + introduce = profile.introduce, + // Personality + hairLength = profile.getHairLengthOrThrow(), + bodyType = profile.getBodyTypeOrThrow(), + height = profile.getHeightOrThrow(), + styles = profile.getStylesList(), + mbti = profile.getMbtiOrThrow(), + drinkingStyle = profile.getAlcoholOrThrow(), + smokingStyle = profile.getSmokeOrThrow(), + personalities = profile.getPersonalitiesList(), + representativeQuestion = profile.getRepresentativeQuestionOrThrow().content, + representativeAnswer = profile.getRepresentativeAnswerOrThrow() + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/ProfileImagesResponse.kt b/src/main/kotlin/codel/member/presentation/response/ProfileImagesResponse.kt new file mode 100644 index 00000000..e1c4cc2e --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/ProfileImagesResponse.kt @@ -0,0 +1,36 @@ +package codel.member.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 프로필 이미지 조회 응답 + */ +@Schema(description = "프로필 이미지 조회 응답") +data class ProfileImagesResponse( + @Schema(description = "얼굴 이미지 목록") + val faceImages: List, + + @Schema(description = "코드 이미지 목록") + val codeImages: List +) + +/** + * 프로필 이미지 정보 + */ +@Schema(description = "프로필 이미지 상세 정보") +data class ProfileImageDto( + @Schema(description = "이미지 ID", example = "101") + val imageId: Long, + + @Schema(description = "이미지 URL", example = "https://example.com/face1.jpg") + val url: String, + + @Schema(description = "이미지 순서 (1부터 시작)", example = "1") + val order: Int, + + @Schema(description = "승인 여부", example = "true") + val isApproved: Boolean, + + @Schema(description = "거절 사유 (거절된 경우만)", example = "얼굴이 명확하게 보이지 않습니다", nullable = true) + val rejectionReason: String? +) diff --git a/src/main/kotlin/codel/member/presentation/response/ProfileRejectionInfoResponse.kt b/src/main/kotlin/codel/member/presentation/response/ProfileRejectionInfoResponse.kt new file mode 100644 index 00000000..e12feab8 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/ProfileRejectionInfoResponse.kt @@ -0,0 +1,43 @@ +package codel.member.presentation.response + +import codel.member.domain.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 프로필 거절 정보 조회 응답 + */ +@Schema(description = "프로필 거절 정보 응답") +data class ProfileRejectionInfoResponse( + @Schema(description = "현재 회원 상태", example = "REJECT") + val status: MemberStatus, + + @Schema(description = "얼굴 이미지 거절 여부", example = "true") + val hasFaceImageRejection: Boolean, + + @Schema(description = "코드 이미지 거절 여부", example = "false") + val hasCodeImageRejection: Boolean, + + @Schema(description = "거절된 얼굴 이미지 목록") + val rejectedFaceImages: List, + + @Schema(description = "거절된 코드 이미지 목록") + val rejectedCodeImages: List +) + +/** + * 거절된 이미지 정보 + */ +@Schema(description = "거절된 이미지 정보") +data class RejectedImageDto( + @Schema(description = "이미지 ID", example = "123") + val imageId: Long, + + @Schema(description = "이미지 URL", example = "https://example.com/image1.jpg") + val url: String, + + @Schema(description = "이미지 순서 (1부터 시작)", example = "1") + val order: Int, + + @Schema(description = "거절 사유", example = "얼굴이 명확하게 보이지 않습니다") + val rejectionReason: String +) diff --git a/src/main/kotlin/codel/member/presentation/response/ReplaceImagesResponse.kt b/src/main/kotlin/codel/member/presentation/response/ReplaceImagesResponse.kt new file mode 100644 index 00000000..6b0379d9 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/ReplaceImagesResponse.kt @@ -0,0 +1,19 @@ +package codel.member.presentation.response + +import codel.member.domain.MemberStatus +import io.swagger.v3.oas.annotations.media.Schema + +/** + * 이미지 교체 응답 + */ +@Schema(description = "이미지 교체 응답") +data class ReplaceImagesResponse( + @Schema(description = "업로드된 이미지 개수", example = "3") + val uploadedCount: Int, + + @Schema(description = "업데이트된 프로필 상태", example = "PENDING") + val profileStatus: MemberStatus, + + @Schema(description = "응답 메시지", example = "이미지가 성공적으로 교체되었습니다. 관리자 승인을 기다려주세요.") + val message: String +) diff --git a/src/main/kotlin/codel/member/presentation/response/ResubmitProfileResponse.kt b/src/main/kotlin/codel/member/presentation/response/ResubmitProfileResponse.kt new file mode 100644 index 00000000..c9a9128b --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/ResubmitProfileResponse.kt @@ -0,0 +1,11 @@ +package codel.member.presentation.response + +import codel.member.domain.MemberStatus + +/** + * 재심사 요청 응답 + */ +data class ResubmitProfileResponse( + val status: MemberStatus, + val message: String +) diff --git a/src/main/kotlin/codel/member/presentation/response/SignUpStatusResponse.kt b/src/main/kotlin/codel/member/presentation/response/SignUpStatusResponse.kt new file mode 100644 index 00000000..e43a5ea0 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/SignUpStatusResponse.kt @@ -0,0 +1,57 @@ +package codel.member.presentation.response + +import codel.member.domain.Member +import codel.member.domain.MemberStatus + +data class SignUpStatusResponse( + val memberId: Long, + val currentStep: MemberStatus, + val nextStep: MemberStatus?, + val completedSteps: List, + val isRegistrationComplete: Boolean, + val canProceedToEssential: Boolean, + val canProceedToPersonality: Boolean, + val canProceedToHidden: Boolean, + val rejectReason : String?, +) { + companion object { + fun from(member: Member): SignUpStatusResponse { + val completedSteps = getCompletedSteps(member.memberStatus) + val nextStep = member.getNextAvailableStep() + + return SignUpStatusResponse( + memberId = member.getIdOrThrow(), + currentStep = member.memberStatus, + nextStep = nextStep, + completedSteps = completedSteps, + isRegistrationComplete = member.memberStatus == MemberStatus.DONE, + canProceedToEssential = member.canProceedToEssential(), + canProceedToPersonality = member.canProceedToPersonality(), + canProceedToHidden = member.canProceedToHidden(), + rejectReason = member.rejectReason + ) + } + + private fun getCompletedSteps(status: MemberStatus): List { + return when (status) { + MemberStatus.WITHDRAWN -> emptyList() + MemberStatus.SIGNUP -> emptyList() + MemberStatus.PHONE_VERIFIED -> listOf(MemberStatus.PHONE_VERIFIED) + MemberStatus.ESSENTIAL_COMPLETED -> listOf( + MemberStatus.PHONE_VERIFIED, MemberStatus.ESSENTIAL_COMPLETED + ) + MemberStatus.PERSONALITY_COMPLETED -> listOf( + MemberStatus.PHONE_VERIFIED, MemberStatus.ESSENTIAL_COMPLETED, + MemberStatus.PERSONALITY_COMPLETED + ) + MemberStatus.HIDDEN_COMPLETED -> listOf( + MemberStatus.PHONE_VERIFIED, MemberStatus.ESSENTIAL_COMPLETED, + MemberStatus.PERSONALITY_COMPLETED, MemberStatus.HIDDEN_COMPLETED + ) + MemberStatus.PENDING, MemberStatus.REJECT, MemberStatus.DONE -> + MemberStatus.values().filter { it != MemberStatus.SIGNUP } + MemberStatus.ADMIN -> emptyList() + } + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/UnlockedMemberProfileResponse.kt b/src/main/kotlin/codel/member/presentation/response/UnlockedMemberProfileResponse.kt new file mode 100644 index 00000000..be2d3bf1 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/UnlockedMemberProfileResponse.kt @@ -0,0 +1,18 @@ +package codel.member.presentation.response + +import codel.member.domain.Member +import java.time.LocalDateTime + +data class UnlockedMemberProfileResponse( + val member: FullProfileResponse, + val unlockedTime: LocalDateTime, +) { + companion object { + fun toResponse(member: Member, unlockedTime: LocalDateTime): UnlockedMemberProfileResponse { + return UnlockedMemberProfileResponse( + FullProfileResponse.createFull(member), // 코드 해제된 경우이므로 Full Profile + unlockedTime + ) + } + } +} diff --git a/src/main/kotlin/codel/member/presentation/response/UpdateCodeImagesResponse.kt b/src/main/kotlin/codel/member/presentation/response/UpdateCodeImagesResponse.kt new file mode 100644 index 00000000..a3f60343 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/UpdateCodeImagesResponse.kt @@ -0,0 +1,12 @@ +package codel.member.presentation.response + +import codel.member.domain.MemberStatus + +/** + * 코드 이미지 수정 응답 + */ +data class UpdateCodeImagesResponse( + val uploadedCount: Int, + val profileStatus: MemberStatus, + val message: String +) diff --git a/src/main/kotlin/codel/member/presentation/response/UpdateRepresentativeQuestionResponse.kt b/src/main/kotlin/codel/member/presentation/response/UpdateRepresentativeQuestionResponse.kt new file mode 100644 index 00000000..47792081 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/response/UpdateRepresentativeQuestionResponse.kt @@ -0,0 +1,16 @@ +package codel.member.presentation.response + +/** + * 대표 질문 수정 응답 + */ +data class UpdateRepresentativeQuestionResponse( + val representativeQuestion: QuestionInfo, + val representativeAnswer: String, + val message: String +) { + data class QuestionInfo( + val id: Long, + val content: String, + val category: String + ) +} diff --git a/src/main/kotlin/codel/member/presentation/swagger/MemberControllerSwagger.kt b/src/main/kotlin/codel/member/presentation/swagger/MemberControllerSwagger.kt new file mode 100644 index 00000000..756d543a --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/swagger/MemberControllerSwagger.kt @@ -0,0 +1,193 @@ +package codel.member.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.member.presentation.request.MemberLoginRequest +import codel.member.presentation.request.WithdrawnRequest +import codel.member.presentation.response.* +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "Member", description = "회원 관련 API") +interface MemberControllerSwagger { + @Operation(summary = "로그인 및 회원 저장 후 분기 반환", description = "소셜 로그인 정보를 기반으로 회원을 저장하고, JWT 토큰과 회원 분기를 반환합니다.") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "성공적으로 로그인 및 회원 저장됨"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun loginMember( + @RequestBody request: MemberLoginRequest, + ): ResponseEntity + + @Operation( + summary = "사용자별 fcm 토큰 받기", + description = "사용자의 디바이스 별 fcm 토큰을 저장합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "fcm 토큰 저장됨"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun saveFcmToken( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody fcmToken: String, + ): ResponseEntity + + @Operation( + summary = "내 프로필 조회", + description = "작성된 사용자의 전체 프로필 정보를 받을 수 있습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "사용자 프로필을 성공적으로 가져옴"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun findMyProfile( + @Parameter(hidden = true) @LoginMember member: Member, + ): ResponseEntity + + @Deprecated("Use /api/v1/recommendations/daily-code-matching instead") + @Operation( + summary = "[Deprecated] 홈 코드 추천 매칭 조회", + description = "⚠️ DEPRECATED: /api/v1/recommendations/daily-code-matching을 사용하세요. " + + "코드 추천 매칭 목록을 받습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "코드 추천 매칭 조회 성공"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun recommendMembers( + @Parameter(hidden = true) @LoginMember member: Member, + ): ResponseEntity + + @Deprecated("Use /api/v1/recommendations/random instead") + @Operation( + summary = "[Deprecated] 홈 파도타기 조회", + description = "⚠️ DEPRECATED: /api/v1/recommendations/random을 사용하세요. " + + "홈 파도 타기 목록을 받습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "홈 파도 타기 목록 조회 성공"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun getRecommendMemberAtTenHourCycle( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "8") size: Int, + ): ResponseEntity> + + @Operation( + summary = "회원 상세 조회", + description = "회원 정보를 상세 조회합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "회원 상세 조회 성공"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun getMemberProfileDetail( + @Parameter(hidden = true) @LoginMember me: Member, + @PathVariable id: Long, + ): ResponseEntity + + @Operation( + summary = "회원 탈퇴", + description = "현재 로그인한 회원의 계정을 탈퇴 처리합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "탈퇴 성공"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun withdrawMember( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody request: WithdrawnRequest, + ): ResponseEntity + + @Operation( + summary = "코드 이미지 수정", + description = """ + 사용자의 코드 이미지를 수정합니다. + + **기존 이미지 유지 기능 추가:** + - existingIds를 통해 유지할 이미지 지정 가능 + - 지정된 이미지는 유지하고, 나머지는 새 이미지로 대체 + - 예: 코드 이미지 3개 중 1개만 교체하고 싶다면, 유지할 2개의 ID를 전달하고 새 이미지 1개 업로드 + + **제약사항:** + - 최종 이미지 개수(유지 + 신규) = 1~3개 + - 수정 후 상태가 PENDING으로 변경되어 재심사가 진행될 수 있습니다 + + **사용 예시:** + - 3개 중 1개만 교체: existingIds=[1,2], codeImages=1개 업로드 + - 3개 중 2개 교체: existingIds=[1], codeImages=2개 업로드 + - 전체 교체: existingIds 생략, codeImages=1~3개 업로드 + + (※ Authorization 헤더에 JWT를 포함시켜야 합니다.) + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "코드 이미지 수정 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 (이미지 개수 오류 등)"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun updateCodeImages( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter(description = "업로드할 코드 이미지 파일 (유지할 이미지 수 + 신규 이미지 수 = 1~3)", required = false) + @RequestParam(value = "codeImages", required = false) codeImages: List?, + @Parameter(description = "유지할 코드 이미지 ID 목록 (선택사항, 콤마로 구분. 예: 1,2,3)", required = false) + @RequestParam(value = "existingIds", required = false) existingIds: List? + ): ResponseEntity + + @Operation( + summary = "대표 질문 및 답변 수정", + description = """ + 사용자의 대표 질문과 답변을 수정합니다. + - 활성화된 질문 ID와 새로운 답변을 입력받습니다 + - 답변은 최대 1000자까지 입력 가능합니다 + (※ Authorization 헤더에 JWT를 포함시켜야 합니다.) + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "대표 질문 수정 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 (비활성화된 질문 선택, 답변 길이 초과 등)"), + ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"), + ApiResponse(responseCode = "404", description = "질문을 찾을 수 없음"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun updateRepresentativeQuestion( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody request: codel.member.presentation.request.UpdateRepresentativeQuestionRequest, + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/member/presentation/swagger/ProfileReviewControllerSwagger.kt b/src/main/kotlin/codel/member/presentation/swagger/ProfileReviewControllerSwagger.kt new file mode 100644 index 00000000..c1648b52 --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/swagger/ProfileReviewControllerSwagger.kt @@ -0,0 +1,371 @@ +package codel.member.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.member.presentation.response.ProfileImagesResponse +import codel.member.presentation.response.ProfileRejectionInfoResponse +import codel.member.presentation.response.ReplaceImagesResponse +import codel.member.presentation.response.ResubmitProfileResponse +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.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "프로필 심사 관리", description = "프로필 심사 거절 및 이미지 교체 API") +interface ProfileReviewControllerSwagger { + + @Operation( + summary = "거절 사유 조회", + description = """ + 프로필 심사 거절 시 거절된 이미지 정보를 조회합니다. + + **사용 시나리오:** + - 프로필이 REJECT 상태일 때 호출 + - 어떤 이미지가 거절되었는지 확인 + - 거절 사유를 사용자에게 전달 + + **응답 정보:** + - 현재 회원 상태 + - 얼굴/코드 이미지 거절 여부 + - 거절된 이미지 목록 및 사유 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "거절 정보 조회 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileRejectionInfoResponse::class), + examples = [ExampleObject( + name = "거절된 이미지가 있는 경우", + value = """ + { + "status": "REJECT", + "hasFaceImageRejection": true, + "hasCodeImageRejection": false, + "rejectedFaceImages": [ + { + "imageId": 123, + "url": "https://example.com/image1.jpg", + "order": 1, + "rejectionReason": "얼굴이 명확하게 보이지 않습니다" + } + ], + "rejectedCodeImages": [] + } + """ + )] + )] + ), + ApiResponse( + responseCode = "401", + description = "인증 실패", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "회원 정보를 찾을 수 없음", + content = [Content(schema = Schema(hidden = true))] + ) + ] + ) + fun getRejectionInfo( + @Parameter(hidden = true) @LoginMember member: Member + ): ResponseEntity + + @Operation( + summary = "프로필 이미지 조회", + description = """ + 프로필의 모든 이미지(얼굴, 코드)를 조회합니다. + + **조회 대상:** + - 승인된 이미지 + - 거절된 이미지 + - 심사 대기 중인 이미지 + + **응답 정보:** + - 이미지 ID, URL, 순서 + - 승인 여부 + - 거절된 경우 거절 사유 + + **활용:** + - 프로필 편집 화면에서 현재 이미지 표시 + - 거절된 이미지 확인 및 교체 UI 구성 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "이미지 조회 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ProfileImagesResponse::class), + examples = [ExampleObject( + name = "혼합된 이미지 상태", + value = """ + { + "faceImages": [ + { + "imageId": 101, + "url": "https://example.com/face1.jpg", + "order": 1, + "isApproved": true, + "rejectionReason": null + }, + { + "imageId": 102, + "url": "https://example.com/face2.jpg", + "order": 2, + "isApproved": false, + "rejectionReason": "얼굴이 명확하게 보이지 않습니다" + } + ], + "codeImages": [ + { + "imageId": 201, + "url": "https://example.com/code1.jpg", + "order": 1, + "isApproved": true, + "rejectionReason": null + } + ] + } + """ + )] + )] + ), + ApiResponse( + responseCode = "401", + description = "인증 실패", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "회원 정보를 찾을 수 없음", + content = [Content(schema = Schema(hidden = true))] + ) + ] + ) + + fun getProfileImages( + @Parameter(hidden = true) @LoginMember member: Member + ): ResponseEntity + + @Operation( + summary = "거절된 이미지 교체", + description = """ + 거절된 이미지를 새로운 이미지로 교체합니다. + + **기존 이미지 유지 기능 추가:** + - existingFaceImageIds, existingCodeImageIds를 통해 유지할 이미지 지정 가능 + - 지정된 이미지는 유지하고, 나머지는 새 이미지로 대체 + - 예: 얼굴 이미지 3개 중 1개만 교체하고 싶다면, 유지할 2개의 ID를 전달하고 새 이미지 1개 업로드 + + **요청 제약사항:** + - 얼굴 이미지: 총 2개 (유지 + 신규) + - 코드 이미지: 총 1~3개 (유지 + 신규) + - 거절된 이미지 타입만 교체 가능 + + **처리 과정:** + 1. existingIds에 없는 기존 이미지 삭제 + 2. 새로운 이미지 업로드 + 3. 유지할 이미지 + 새 이미지로 프로필 구성 + 4. 프로필 상태를 PENDING으로 변경 + 5. 관리자 재심사 대기 + + **사용 예시:** + - 얼굴 이미지 2개 중 1개만 교체: existingFaceImageIds=[123], faceImages=1개 + - 코드 이미지 3개 중 2개 교체: existingCodeImageIds=[456], codeImages=2개 + - 전체 교체: existingIds 생략, 모든 이미지 업로드 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "이미지 교체 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ReplaceImagesResponse::class), + examples = [ExampleObject( + name = "교체 성공", + value = """ + { + "uploadedCount": 1, + "profileStatus": "PENDING", + "message": "얼굴 이미지 2개 (유지: 1개, 신규: 1개). 심사가 다시 진행됩니다" + } + """ + )] + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 (이미지 개수 부족 등)", + content = [Content( + mediaType = "application/json", + examples = [ExampleObject( + name = "이미지 개수 오류", + value = """ + { + "message": "얼굴 이미지는 총 2개여야 합니다. (현재: 유지 1개 + 신규 0개 = 1개)" + } + """ + )] + )] + ) + ] + ) + + fun replaceImages( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter( + description = "교체할 얼굴 이미지 (얼굴 이미지가 거절된 경우, 유지할 이미지 수 + 신규 이미지 수 = 2)", + content = [Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)] + ) + @RequestPart(value = "faceImages", required = false) faceImages: List?, + @Parameter( + description = "교체할 코드 이미지 (코드 이미지가 거절된 경우, 유지할 이미지 수 + 신규 이미지 수 = 1~3)", + content = [Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)] + ) + @RequestPart(value = "codeImages", required = false) codeImages: List?, + @Parameter( + description = "유지할 얼굴 이미지 ID 목록 (선택사항, 콤마로 구분. 예: 1,2,3)" + ) + @RequestParam(value = "existingFaceImageIds", required = false) existingFaceImageIds: List?, + @Parameter( + description = "유지할 코드 이미지 ID 목록 (선택사항, 콤마로 구분. 예: 10,11,12)" + ) + @RequestParam(value = "existingCodeImageIds", required = false) existingCodeImageIds: List? + ): ResponseEntity + + @Operation( + summary = "재심사 통합 제출 (코드/얼굴/인증 이미지)", + description = """ + 프로필 재심사를 위한 모든 이미지를 한 번에 제출합니다. + + **사용 시나리오:** + - REJECT 상태에서 재심사 요청 + - 코드/얼굴 이미지 + 본인 인증 이미지를 한 번에 제출 + - 기존 이미지 유지 + 신규 이미지 추가 가능 + + **요청 제약사항:** + - 회원 상태: REJECT 상태여야 함 + - 얼굴 이미지: 총 2개 (유지 + 신규) + - 코드 이미지: 총 1~3개 (유지 + 신규) + - 인증 이미지: 필수 (1개) + - standardImageId: 표준 인증 이미지 ID 필수 + + **처리 과정:** + 1. 얼굴/코드 이미지 처리 (유지할 이미지 + 신규 이미지) + 2. 기존 인증 이미지 소프트 삭제 + 3. 새 인증 이미지 업로드 및 저장 + 4. 회원 상태를 HIDDEN_COMPLETED으로 변경 + 5. 관리자 재심사 대기 + + **기존 이미지 유지:** + - existingFaceImageIds: 유지할 얼굴 이미지 ID 목록 + - existingCodeImageIds: 유지할 코드 이미지 ID 목록 + - 지정하지 않으면 모든 이미지 교체 + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "재심사 제출 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ResubmitProfileResponse::class), + examples = [ExampleObject( + name = "재심사 제출 완료", + value = """ + { + "status": "HIDDEN_COMPLETED", + "message": "얼굴 이미지 2개 반영. 코드 이미지 2개 반영. 본인 인증 이미지 제출 완료. 심사 대기 상태로 변경되었습니다" + } + """ + )] + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = [Content( + mediaType = "application/json", + examples = [ExampleObject( + name = "상태 오류", + value = """ + { + "message": "재심사 요청은 REJECT 상태에서만 가능합니다. 현재 상태: PENDING" + } + """ + ), ExampleObject( + name = "이미지 개수 오류", + value = """ + { + "message": "얼굴 이미지는 총 2개여야 합니다. (현재: 유지 1개 + 신규 0개 = 1개)" + } + """ + )] + )] + ), + ApiResponse( + responseCode = "401", + description = "인증 실패", + content = [Content(schema = Schema(hidden = true))] + ), + ApiResponse( + responseCode = "404", + description = "회원 또는 표준 인증 이미지를 찾을 수 없음", + content = [Content(schema = Schema(hidden = true))] + ) + ] + ) + fun resubmitProfile( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter( + description = "얼굴 이미지 (신규 업로드, 유지할 이미지 수 + 신규 이미지 수 = 2)", + content = [Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)] + ) + @RequestPart(value = "faceImages", required = false) faceImages: List?, + @Parameter( + description = "코드 이미지 (신규 업로드, 유지할 이미지 수 + 신규 이미지 수 = 1~3)", + content = [Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)] + ) + @RequestPart(value = "codeImages", required = false) codeImages: List?, + @Parameter( + description = "유지할 얼굴 이미지 ID 목록 (선택사항, 콤마로 구분)" + ) + @RequestParam(value = "existingFaceImageIds", required = false) existingFaceImageIds: List?, + @Parameter( + description = "유지할 코드 이미지 ID 목록 (선택사항, 콤마로 구분)" + ) + @RequestParam(value = "existingCodeImageIds", required = false) existingCodeImageIds: List?, + @Parameter( + description = "표준 인증 이미지 ID (필수)" + ) + @RequestParam(value = "standardImageId") standardImageId: Long, + @Parameter( + description = "본인 인증 이미지 (필수)", + content = [Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)] + ) + @RequestPart(value = "verificationImage") verificationImage: MultipartFile + ): ResponseEntity +} \ No newline at end of file diff --git a/src/main/kotlin/codel/member/presentation/swagger/SignupControllerSwagger.kt b/src/main/kotlin/codel/member/presentation/swagger/SignupControllerSwagger.kt new file mode 100644 index 00000000..ad2caf0a --- /dev/null +++ b/src/main/kotlin/codel/member/presentation/swagger/SignupControllerSwagger.kt @@ -0,0 +1,208 @@ +package codel.member.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.member.presentation.request.EssentialProfileRequest +import codel.member.presentation.request.HiddenProfileRequest +import codel.member.presentation.request.PersonalityProfileRequest +import codel.member.presentation.response.SignUpStatusResponse +import codel.verification.presentation.response.VerificationImageResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "회원가입", description = "단계별 회원가입 관련 API") +interface SignupControllerSwagger { + + @Operation( + summary = "회원가입 진행 상태 조회", + description = "현재 회원의 회원가입 진행 상태와 다음 단계를 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "조회 성공"), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "404", description = "회원 정보 없음") + ] + ) + fun getSignupStatus( + @Parameter(hidden = true) @LoginMember member: Member + ): ResponseEntity + + @Operation( + summary = "전화번호 인증 완료", + description = "전화번호 인증을 완료하고 다음 단계(Essential Profile)로 진행할 수 있도록 상태를 변경합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "인증 완료"), + ApiResponse(responseCode = "400", description = "잘못된 인증 정보 또는 단계 오류"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun completePhoneVerification( + @Parameter(hidden = true) @LoginMember member: Member, + ): ResponseEntity + + @Operation( + summary = "Open Profile 정보 등록", + description = "기본 프로필 정보를 등록합니다. (이미지 제외)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "등록 성공"), + ApiResponse(responseCode = "400", description = "잘못된 입력 데이터 또는 단계 오류"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun registerEssentialProfile( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody request: EssentialProfileRequest + ): ResponseEntity + + @Operation( + summary = "Open Profile 이미지 등록", + description = "기본 프로필 이미지를 등록하고 Open Profile을 완료합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "등록 완료"), + ApiResponse(responseCode = "400", description = "잘못된 이미지 파일 또는 단계 오류"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun registerEssentialImages( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter(description = "코드 이미지 파일들 (1-3장)") images: List + ): ResponseEntity + + @Operation( + summary = "Personality Profile 등록", + description = "성격/취향 프로필 정보를 등록하고 Personality Profile을 완료합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "등록 완료"), + ApiResponse(responseCode = "400", description = "잘못된 입력 데이터 또는 단계 오류"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun registerPersonalityProfile( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody request: PersonalityProfileRequest + ): ResponseEntity + + @Operation( + summary = "Hidden Profile 정보 등록", + description = "히든 프로필 정보를 등록합니다. (이미지 제외)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "등록 성공"), + ApiResponse(responseCode = "400", description = "잘못된 입력 데이터 또는 단계 오류"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun registerHiddenProfile( + @Parameter(hidden = true) @LoginMember member: Member, + @RequestBody request: HiddenProfileRequest + ): ResponseEntity + + @Operation( + summary = "Hidden Profile 이미지 등록", + description = """ + 히든 프로필 이미지를 등록합니다. 앱 버전과 회원 상태에 따라 다르게 동작합니다. + + **정상 가입 (PERSONALITY_COMPLETED):** + - 히든 프로필 이미지를 등록하고 다음 단계로 진행합니다. + + **재심사 (REJECT):** + - 구버전 앱(1.2.0 미만): 히든 이미지를 등록하고 PENDING 상태로 변경 (하위호환) + - 신규 앱(1.2.0 이상): 새로운 재심사 API(/v1/profile/review/resubmit)를 사용하도록 안내 + + **X-App-Version 헤더:** + - 앱 버전을 명시하지 않으면 구버전으로 간주되어 하위호환 로직이 적용됩니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "등록 완료"), + ApiResponse(responseCode = "400", description = "잘못된 이미지 파일, 단계 오류, 또는 신규 앱에서 재심사 시도"), + ApiResponse(responseCode = "401", description = "인증 실패") + ] + ) + fun registerHiddenImages( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter(description = "얼굴 이미지 파일들 (3장)") images: List, + @Parameter(description = "앱 버전 (예: 1.2.0)") appVersion: String? + ): ResponseEntity + + @Operation( + summary = "사용자 인증 이미지 제출", + description = """ + 표준 이미지를 참고하여 촬영한 본인 인증 이미지를 제출합니다. + + **요구사항:** + - 회원 상태가 HIDDEN_COMPLETED 또는 REJECT여야 함 + - multipart/form-data로 전송 + - 이미지 파일 크기: 최대 10MB + - 허용된 확장자: jpg, jpeg, png, gif, webp + + **제출 과정:** + 1. 표준 이미지 조회 (GET /v1/verification/standard-image) + 2. 표준 이미지를 보고 동일한 자세로 촬영 + 3. 촬영한 이미지를 본 API로 제출 + 4. 회원 상태가 PENDING (심사 대기)으로 변경 + + **재제출:** + - 재제출 가능 (기존 이미지는 유지, 이력 관리) + - 거절 후 재제출 시에도 동일한 API 사용 + + ※ Authorization 헤더에 JWT 토큰을 포함해야 합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "인증 이미지 제출 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = VerificationImageResponse::class) + )] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 - 회원 상태가 올바르지 않거나 파일 검증 실패", + content = [Content()] + ), + ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자 - JWT 토큰이 없거나 유효하지 않음", + content = [Content()] + ), + ApiResponse( + responseCode = "404", + description = "표준 인증 이미지를 찾을 수 없음", + content = [Content()] + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류 - S3 업로드 실패 등", + content = [Content()] + ) + ] + ) + fun submitVerificationImage( + @Parameter(hidden = true) @LoginMember member: Member, + @Parameter(description = "참조한 표준 이미지 ID") standardImageId: Long, + @Parameter(description = "사용자가 촬영한 인증 이미지 파일") userImage: MultipartFile + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/notification/business/AsyncNotificationService.kt b/src/main/kotlin/codel/notification/business/AsyncNotificationService.kt new file mode 100644 index 00000000..fa4a62b1 --- /dev/null +++ b/src/main/kotlin/codel/notification/business/AsyncNotificationService.kt @@ -0,0 +1,194 @@ +package codel.notification.business + +import codel.config.Loggable +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import java.util.concurrent.CompletableFuture + +/** + * 비동기 알림 전송 서비스 + * + * - 대량 알림을 병렬로 빠르게 처리 + * - CompletableFuture를 사용하여 결과 추적 가능 + * - 배치 처리 지원 + */ +@Service +class AsyncNotificationService( + private val notificationService: NotificationService +) : IAsyncNotificationService, Loggable { + + /** + * 단일 알림을 비동기로 전송 + * + * @return CompletableFuture + */ + @Async("notificationExecutor") + override fun sendAsync(notification: Notification): CompletableFuture { + return try { + notificationService.send(notification) + CompletableFuture.completedFuture( + NotificationResult.success( + targetId = notification.targetId ?: "unknown", + type = notification.type + ) + ) + } catch (e: Exception) { + log.warn(e) { "❌ 비동기 알림 전송 실패 - targetId: ${notification.targetId}" } + CompletableFuture.completedFuture( + NotificationResult.failure( + targetId = notification.targetId ?: "unknown", + type = notification.type, + error = e.message ?: "Unknown error" + ) + ) + } + } + + /** + * 여러 알림을 비동기 배치로 전송 + * + * @param notifications 전송할 알림 리스트 + * @return CompletableFuture + */ + override fun sendBatchAsync(notifications: List): CompletableFuture { + val startTime = System.currentTimeMillis() + + // 모든 알림을 비동기로 전송 + val futures = notifications.map { notification -> + sendAsync(notification) + } + + // 모든 작업이 완료될 때까지 대기 + return CompletableFuture.allOf(*futures.toTypedArray()) + .thenApply { + val results = futures.map { it.join() } + val duration = System.currentTimeMillis() - startTime + + BatchNotificationResult( + total = notifications.size, + success = results.count { it.success }, + failure = results.count { !it.success }, + results = results, + durationMs = duration + ) + } + } + + /** + * FCM 전용: 배치 처리 + * FCM은 최대 500개씩 배치로 전송 가능 + */ + @Async("notificationExecutor") + override fun sendFcmBatchAsync( + tokens: List, + title: String, + body: String + ): CompletableFuture { + val startTime = System.currentTimeMillis() + + // 500개씩 청크로 나누기 + val chunks = tokens.chunked(500) + + log.info { "📦 FCM 배치 전송 시작 - 총 ${tokens.size}개를 ${chunks.size}개 배치로 분할" } + + val futures = chunks.map { chunk -> + sendFcmChunkAsync(chunk, title, body) + } + + return CompletableFuture.allOf(*futures.toTypedArray()) + .thenApply { + val results = futures.flatMap { it.join().results } + val duration = System.currentTimeMillis() - startTime + + val successCount = results.count { it.success } + val failureCount = results.count { !it.success } + + log.info { + "✅ FCM 배치 전송 완료 - " + + "성공: $successCount, 실패: $failureCount, " + + "소요시간: ${duration}ms" + } + + BatchNotificationResult( + total = tokens.size, + success = successCount, + failure = failureCount, + results = results, + durationMs = duration + ) + } + } + + /** + * FCM 청크 단위 전송 (최대 500개) + */ + private fun sendFcmChunkAsync( + tokens: List, + title: String, + body: String + ): CompletableFuture { + val notifications = tokens.map { token -> + Notification( + type = NotificationType.MOBILE, + targetId = token, + title = title, + body = body + ) + } + + return sendBatchAsync(notifications) + } +} + +/** + * 단일 알림 전송 결과 + */ +data class NotificationResult( + val targetId: String, + val type: NotificationType, + val success: Boolean, + val error: String? = null, + val timestamp: Long = System.currentTimeMillis() +) { + companion object { + fun success(targetId: String, type: NotificationType) = NotificationResult( + targetId = targetId, + type = type, + success = true + ) + + fun failure(targetId: String, type: NotificationType, error: String) = NotificationResult( + targetId = targetId, + type = type, + success = false, + error = error + ) + } +} + +/** + * 배치 알림 전송 결과 + */ +data class BatchNotificationResult( + val total: Int, + val success: Int, + val failure: Int, + val results: List, + val durationMs: Long +) { + val successRate: Double + get() = if (total > 0) (success.toDouble() / total) * 100 else 0.0 + + fun getFailedTargets(): List { + return results.filter { !it.success }.map { it.targetId } + } + + fun getErrorSummary(): Map { + return results + .filter { !it.success } + .groupBy { it.error ?: "Unknown error" } + .mapValues { it.value.size } + } +} diff --git a/src/main/kotlin/codel/notification/business/IAsyncNotificationService.kt b/src/main/kotlin/codel/notification/business/IAsyncNotificationService.kt new file mode 100644 index 00000000..c3b2aee8 --- /dev/null +++ b/src/main/kotlin/codel/notification/business/IAsyncNotificationService.kt @@ -0,0 +1,29 @@ +package codel.notification.business + +import codel.notification.domain.Notification +import java.util.concurrent.CompletableFuture + +/** + * 비동기 알림 전송 서비스 인터페이스 + */ +interface IAsyncNotificationService { + + /** + * 단일 알림을 비동기로 전송 + */ + fun sendAsync(notification: Notification): CompletableFuture + + /** + * 여러 알림을 비동기 배치로 전송 + */ + fun sendBatchAsync(notifications: List): CompletableFuture + + /** + * FCM 전용: 배치 처리 + */ + fun sendFcmBatchAsync( + tokens: List, + title: String, + body: String + ): CompletableFuture +} diff --git a/src/main/kotlin/codel/notification/business/MatchingNotificationScheduler.kt b/src/main/kotlin/codel/notification/business/MatchingNotificationScheduler.kt new file mode 100644 index 00000000..38c0d316 --- /dev/null +++ b/src/main/kotlin/codel/notification/business/MatchingNotificationScheduler.kt @@ -0,0 +1,198 @@ +package codel.notification.business + +import codel.config.Loggable +import codel.member.domain.MemberRepository +import codel.member.domain.MemberStatus +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +class MatchingNotificationScheduler( + private val memberRepository: MemberRepository, + private val asyncNotificationService: IAsyncNotificationService, + private val notificationService: NotificationService +) : Loggable { + + /** + * 매일 오전 10시에 실행 + * cron: 초 분 시 일 월 요일 + */ + @Scheduled(cron = "0 0 10 * * *", zone = "Asia/Seoul") + @Transactional(readOnly = true) + fun sendMorningMatchingNotification() { + log.info { "🌅 오전 10시 매칭 알림 전송 시작" } + sendMatchingNotificationToAllUsersAsync("morning") + } + + /** + * 매일 오후 10시에 실행 + */ + @Scheduled(cron = "0 0 22 * * *", zone = "Asia/Seoul") + @Transactional(readOnly = true) + fun sendEveningMatchingNotification() { + log.info { "🌙 오후 10시 매칭 알림 전송 시작" } + sendMatchingNotificationToAllUsersAsync("evening") + } + + /** + * 비동기 배치 처리로 알림 전송 (개선 버전) + */ + private fun sendMatchingNotificationToAllUsersAsync(timeSlot: String) { + val startTime = System.currentTimeMillis() + + // DONE 상태의 활성 회원만 조회 + val activeMembers = memberRepository.findByMemberStatus(MemberStatus.DONE) + + if (activeMembers.isEmpty()) { + log.info { "⚠️ 알림 전송 대상이 없습니다." } + sendDiscordNotification(timeSlot, 0, 0, 0, 0, 0) + return + } + + log.info { "📊 알림 전송 대상: ${activeMembers.size}명" } + + // FCM 토큰이 있는 회원만 필터링 + val membersWithToken = activeMembers.filter { it.fcmToken != null } + val noTokenCount = activeMembers.size - membersWithToken.size + + if (membersWithToken.isEmpty()) { + log.warn { "⚠️ FCM 토큰이 있는 회원이 없습니다." } + sendDiscordNotification(timeSlot, activeMembers.size, 0, 0, noTokenCount, 0) + return + } + + log.info { "✅ FCM 토큰 보유: ${membersWithToken.size}명, 토큰 없음: ${noTokenCount}명" } + + // 알림 생성 + val title = "새로운 인연을 만나보세요 💝" + val body = "지금 새로운 프로필이 업데이트되었어요! 오늘의 인연을 확인해보세요." + + val tokens = membersWithToken.mapNotNull { it.fcmToken } + + // 비동기 배치 전송 + val resultFuture = asyncNotificationService.sendFcmBatchAsync(tokens, title, body) + + // 결과 대기 + val result = try { + resultFuture.get() // CompletableFuture 완료 대기 + } catch (e: Exception) { + log.error(e) { "❌ 비동기 알림 전송 중 오류 발생" } + sendDiscordNotification(timeSlot, activeMembers.size, 0, membersWithToken.size, noTokenCount, 0) + return + } + + val duration = System.currentTimeMillis() - startTime + + log.info { + """ + ✅ $timeSlot 매칭 알림 전송 완료 (${duration}ms) + - 총 대상: ${activeMembers.size}명 + - 성공: ${result.success}명 + - 실패: ${result.failure}명 + - 토큰 없음: ${noTokenCount}명 + - 성공률: ${String.format("%.1f%%", result.successRate)} + - 평균 처리: ${if (result.total > 0) result.durationMs / result.total else 0}ms/명 + """.trimIndent() + } + + // 실패한 경우 상세 로그 + if (result.failure > 0) { + val errorSummary = result.getErrorSummary() + log.warn { + """ + ⚠️ 실패 상세 정보: + ${errorSummary.entries.joinToString("\n") { " - ${it.key}: ${it.value}건" }} + """.trimIndent() + } + } + + // 디스코드로 결과 전송 + sendDiscordNotification( + timeSlot = timeSlot, + totalCount = activeMembers.size, + successCount = result.success, + failCount = result.failure, + noTokenCount = noTokenCount, + duration = duration + ) + } + + private fun sendDiscordNotification( + timeSlot: String, + totalCount: Int, + successCount: Int, + failCount: Int, + noTokenCount: Int, + duration: Long + ) { + try { + val timeSlotKorean = when(timeSlot) { + "morning" -> "🌅 오전 10시" + "evening" -> "🌙 오후 10시" + else -> "🧪 테스트" + } + + val currentTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + + val successRate = if (totalCount > 0) { + String.format("%.1f%%", (successCount.toDouble() / totalCount) * 100) + } else { + "0.0%" + } + + val statusEmoji = when { + totalCount == 0 -> "⚠️" + failCount == 0 -> "✅" + failCount < totalCount * 0.1 -> "⚡" + else -> "⚠️" + } + + val notification = Notification( + type = NotificationType.DISCORD, + targetId = null, + title = "$statusEmoji 매칭 알림 전송 완료 (비동기)", + body = """ + **$timeSlotKorean 매칭 알림 전송 결과** + + 📊 **전송 통계** + • 총 대상: **${totalCount}명** + • 성공: **${successCount}명** ($successRate) + • 실패: **${failCount}명** + • 토큰 없음: **${noTokenCount}명** + + ⏱️ **처리 시간** + • 소요 시간: **${duration}ms** (${String.format("%.2f", duration / 1000.0)}초) + • 평균 처리: **${if (totalCount > 0) duration / totalCount else 0}ms/명** + + 🚀 **성능 개선** + • 비동기 배치 처리 적용 + • FCM 배치 API 활용 (최대 500개/배치) + + 🕐 **실행 시각** + • $currentTime (KST) + """.trimIndent() + ) + + notificationService.send(notification) + log.info { "✅ 디스코드 알림 전송 완료" } + } catch (e: Exception) { + log.warn(e) { "❌ 디스코드 알림 전송 실패" } + } + } + + /** + * 테스트용 - 매 1분마다 실행 (개발/테스트용) + * 프로덕션에서는 제거하거나 주석 처리 + */ +// @Scheduled(cron = "0 */1 * * * *", zone = "Asia/Seoul") +// @Transactional(readOnly = true) +// fun sendTestNotification() { +// log.info { "🧪 테스트 알림 전송 시작 (1분마다)" } +// sendMatchingNotificationToAllUsersAsync("test") +// } +} diff --git a/src/main/kotlin/codel/notification/business/NotificationService.kt b/src/main/kotlin/codel/notification/business/NotificationService.kt new file mode 100644 index 00000000..51783938 --- /dev/null +++ b/src/main/kotlin/codel/notification/business/NotificationService.kt @@ -0,0 +1,28 @@ +package codel.notification.business + +import codel.notification.domain.NotificationType +import codel.notification.domain.sender.NotificationSender +import org.springframework.stereotype.Service +import codel.notification.domain.Notification as CodelNotification + +@Service +class NotificationService( + val senders: List, +) { + fun send(notification: CodelNotification) { + val matchingSenders = + if (notification.type == NotificationType.ALL) { + senders + } else { + senders.filter { it.supports(notification.type) } + } + + if (matchingSenders.isEmpty()) { + throw IllegalArgumentException("지원하지 않는 알림 타입입니다: ${notification.type}") + } + + matchingSenders.forEach { sender -> + sender.send(notification) + } + } +} diff --git a/src/main/kotlin/codel/notification/domain/Notification.kt b/src/main/kotlin/codel/notification/domain/Notification.kt new file mode 100644 index 00000000..e56d79e4 --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/Notification.kt @@ -0,0 +1,9 @@ +package codel.notification.domain + +data class Notification( + val type: NotificationType, + val targetId: String?, // 사용자 ID or null (예: Discord) + val title: String, + val body: String, + val data: Map? = null, // FCM data payload (chatRoomId, senderId, type 등) +) diff --git a/src/main/kotlin/codel/notification/domain/NotificationDataType.kt b/src/main/kotlin/codel/notification/domain/NotificationDataType.kt new file mode 100644 index 00000000..187ac464 --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/NotificationDataType.kt @@ -0,0 +1,54 @@ +package codel.notification.domain + +/** + * FCM 알림 데이터 타입 + * 클라이언트에서 알림을 받았을 때 어떤 종류의 알림인지 구분하기 위한 타입 + */ +enum class NotificationDataType(val value: String) { + /** + * 일반 채팅 메시지 + */ + CHAT("CHAT"), + + /** + * 코드 해제 요청 + */ + CODE_UNLOCK_REQUEST("CODE_UNLOCK_REQUEST"), + + /** + * 코드 해제 완료 + */ + CODE_UNLOCKED("CODE_UNLOCKED"), + + /** + * 시그널(좋아요) 수신 + */ + SIGNAL("SIGNAL"), + + /** + * 매칭 성공 + */ + MATCHING("MATCHING"), + + /** + * 프로필 승인 + */ + PROFILE_APPROVED("PROFILE_APPROVED"), + + /** + * 프로필 반려 + */ + PROFILE_REJECTED("PROFILE_REJECTED"), + + /** + * 일일 매칭 알림 (데일리 코드) + */ + DAILY_MATCHING("DAILY_MATCHING"), + + /** + * 시스템 공지사항 + */ + NOTICE("NOTICE"); + + override fun toString(): String = value +} diff --git a/src/main/kotlin/codel/notification/domain/NotificationType.kt b/src/main/kotlin/codel/notification/domain/NotificationType.kt new file mode 100644 index 00000000..b0da6e82 --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/NotificationType.kt @@ -0,0 +1,7 @@ +package codel.notification.domain + +enum class NotificationType { + ALL, + DISCORD, + MOBILE, +} diff --git a/src/main/kotlin/codel/notification/domain/sender/DiscordNotificationSender.kt b/src/main/kotlin/codel/notification/domain/sender/DiscordNotificationSender.kt new file mode 100644 index 00000000..30c0f26a --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/sender/DiscordNotificationSender.kt @@ -0,0 +1,55 @@ +package codel.notification.domain.sender + +import codel.notification.domain.NotificationType +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate +import java.time.OffsetDateTime +import codel.notification.domain.Notification as CodelNotification + +@Component +@ConditionalOnProperty(name = ["discord.webhook.url"]) +class DiscordNotificationSender( + private val restTemplate: RestTemplate, + @Value("\${discord.webhook.url}") + private val webhookUrl: String, +) : NotificationSender { + override fun supports(type: NotificationType): Boolean = type == NotificationType.DISCORD || type == NotificationType.ALL + + override fun send(notification: CodelNotification): String { + val now = OffsetDateTime.now().toString() // ISO 8601 포맷 (Z 포함) + + val embedBody = createEmbedBody(notification, now) + + try { + restTemplate.postForEntity(webhookUrl, embedBody, String::class.java) + } catch (e: Exception) { + throw RuntimeException("디스코드 메시지 전송 실패: ${e.message}", e) + } + return "ok" + } + + private fun createEmbedBody( + notification: CodelNotification, + now: String + ): Map>> { + // title에 이모지가 포함되어 있으면 그대로 사용, 아니면 기본 이모지 추가 + val titleWithEmoji = if (notification.title.matches(Regex(".*[\\p{So}\\p{Cn}].*"))) { + notification.title + } else { + "📩 ${notification.title}" + } + + // body를 필드로 분리할지 description으로 사용할지 결정 + val embedMap = mutableMapOf( + "title" to titleWithEmoji, + "description" to notification.body, + "color" to 3447003, // 파란색 계열 + "footer" to mapOf("text" to "🕒 CODEL 시스템 알림"), + "timestamp" to now + ) + + return mapOf("embeds" to listOf(embedMap)) + } +} diff --git a/src/main/kotlin/codel/notification/domain/sender/FcmNotificationSender.kt b/src/main/kotlin/codel/notification/domain/sender/FcmNotificationSender.kt new file mode 100644 index 00000000..074356ee --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/sender/FcmNotificationSender.kt @@ -0,0 +1,133 @@ +package codel.notification.domain.sender + +import codel.config.Loggable +import codel.notification.domain.NotificationDataType +import codel.notification.domain.NotificationType +import codel.notification.exception.NotificationException +import com.google.firebase.messaging.* +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import codel.notification.domain.Notification as CodelNotification + +@Component +class FcmNotificationSender : NotificationSender, Loggable { + override fun supports(type: NotificationType): Boolean = type == NotificationType.MOBILE + + override fun send(notification: CodelNotification): String { + val messageBuilder = Message + .builder() + .setToken(notification.targetId) + .setNotification( + Notification + .builder() + .setTitle(notification.title) + .setBody(notification.body) + .build(), + ) + + // 기본값으로 NOTICE 타입 설정 후, 요청 data로 덮어쓰기 + val defaultData = mapOf("type" to NotificationDataType.NOTICE.value) + val dataToSend = defaultData + (notification.data ?: emptyMap()) + messageBuilder.putAllData(dataToSend) + + val message = messageBuilder.build() + + return try { + val response = FirebaseMessaging.getInstance().send(message) + log.debug { "FCM 메시지 전송 성공: messageId=$response" } + response + } catch (e: FirebaseMessagingException) { + handleFcmError(e, notification.targetId) + throw NotificationException(HttpStatus.BAD_GATEWAY, "알림 전송중 오류가 발생했습니다: ${e.messagingErrorCode}") + } + } + + /** + * FCM 배치 전송 (최대 500개) + * + * @param notifications 전송할 알림 리스트 (최대 500개) + * @return BatchResponse + */ + fun sendBatch(notifications: List): BatchResponse { + require(notifications.size <= 500) { "FCM 배치는 최대 500개까지만 가능합니다" } + + val messages = notifications.map { notification -> + val messageBuilder = Message + .builder() + .setToken(notification.targetId) + .setNotification( + Notification + .builder() + .setTitle(notification.title) + .setBody(notification.body) + .build(), + ) + + // 기본값으로 NOTICE 타입 설정 후, 요청 data로 덮어쓰기 + val defaultData = mapOf("type" to NotificationDataType.NOTICE.value) + val dataToSend = defaultData + (notification.data ?: emptyMap()) + messageBuilder.putAllData(dataToSend) + + messageBuilder.build() + } + + return try { + val batchResponse = FirebaseMessaging.getInstance().sendAll(messages) + + log.info { + "📦 FCM 배치 전송 완료 - " + + "성공: ${batchResponse.successCount}, " + + "실패: ${batchResponse.failureCount}" + } + + // 실패한 항목 로깅 + batchResponse.responses.forEachIndexed { index, response -> + if (!response.isSuccessful) { + val token = notifications[index].targetId + log.warn { "❌ FCM 배치 전송 실패 [${index}] - token=$token, error=${response.exception?.message}" } + + if (response.exception is FirebaseMessagingException) { + handleFcmError(response.exception as FirebaseMessagingException, token) + } + } + } + + batchResponse + } catch (e: FirebaseMessagingException) { + log.error(e) { "🔴 FCM 배치 전송 중 오류 발생" } + throw NotificationException(HttpStatus.BAD_GATEWAY, "배치 알림 전송중 오류가 발생했습니다: ${e.messagingErrorCode}") + } + } + + private fun handleFcmError(e: FirebaseMessagingException, token: String?) { + when (e.messagingErrorCode) { + MessagingErrorCode.INVALID_ARGUMENT -> { + log.warn { "🔴 FCM 잘못된 토큰: token=$token" } + // TODO: 토큰 무효화 처리 필요 + } + MessagingErrorCode.UNREGISTERED -> { + log.warn { "🔴 FCM 등록되지 않은 토큰 (앱 삭제됨): token=$token" } + // TODO: 토큰 삭제 처리 필요 + } + MessagingErrorCode.SENDER_ID_MISMATCH -> { + log.error { "🔴 FCM Sender ID 불일치: token=$token" } + } + MessagingErrorCode.QUOTA_EXCEEDED -> { + log.error { "🔴 FCM 할당량 초과! 즉시 확인 필요!" } + } + MessagingErrorCode.UNAVAILABLE -> { + log.warn { "⚠️ FCM 서버 일시적 장애: token=$token" } + // TODO: 재시도 로직 고려 + } + MessagingErrorCode.INTERNAL -> { + log.error(e) { "🔴 FCM 내부 오류: token=$token" } + } + MessagingErrorCode.THIRD_PARTY_AUTH_ERROR -> { + log.error { "🔴 FCM 인증 오류: Firebase 설정 확인 필요" } + } + else -> { + log.error(e) { "🔴 FCM 알 수 없는 오류: errorCode=${e.messagingErrorCode}, token=$token" } + } + } + } +} diff --git a/src/main/kotlin/codel/notification/domain/sender/NotificationSender.kt b/src/main/kotlin/codel/notification/domain/sender/NotificationSender.kt new file mode 100644 index 00000000..b605f0a0 --- /dev/null +++ b/src/main/kotlin/codel/notification/domain/sender/NotificationSender.kt @@ -0,0 +1,10 @@ +package codel.notification.domain.sender + +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType + +interface NotificationSender { + fun supports(type: NotificationType): Boolean + + fun send(notification: Notification): String +} diff --git a/src/main/kotlin/codel/notification/exception/NotificationException.kt b/src/main/kotlin/codel/notification/exception/NotificationException.kt new file mode 100644 index 00000000..efac76ed --- /dev/null +++ b/src/main/kotlin/codel/notification/exception/NotificationException.kt @@ -0,0 +1,9 @@ +package codel.notification.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class NotificationException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/notification/presentation/NotificationSchedulerTestController.kt b/src/main/kotlin/codel/notification/presentation/NotificationSchedulerTestController.kt new file mode 100644 index 00000000..a1b3191b --- /dev/null +++ b/src/main/kotlin/codel/notification/presentation/NotificationSchedulerTestController.kt @@ -0,0 +1,47 @@ +package codel.notification.presentation + +import codel.notification.business.MatchingNotificationScheduler +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * 스케줄러 테스트용 컨트롤러 + * dev, local 프로파일에서만 활성화됨 + */ +@RestController +@RequestMapping("/api/v1/test/scheduler") +@Profile("dev", "local") +@Tag(name = "스케줄러 테스트 API (개발용)") +class NotificationSchedulerTestController( + private val matchingNotificationScheduler: MatchingNotificationScheduler +) { + + @PostMapping("/matching/morning") + @Operation(summary = "오전 매칭 알림 즉시 전송", description = "스케줄러를 기다리지 않고 즉시 오전 매칭 알림을 전송합니다.") + fun testMorningNotification(): TestSchedulerResponse { + matchingNotificationScheduler.sendMorningMatchingNotification() + return TestSchedulerResponse( + success = true, + message = "오전 매칭 알림 전송 완료. 로그를 확인하세요." + ) + } + + @PostMapping("/matching/evening") + @Operation(summary = "오후 매칭 알림 즉시 전송", description = "스케줄러를 기다리지 않고 즉시 오후 매칭 알림을 전송합니다.") + fun testEveningNotification(): TestSchedulerResponse { + matchingNotificationScheduler.sendEveningMatchingNotification() + return TestSchedulerResponse( + success = true, + message = "오후 매칭 알림 전송 완료. 로그를 확인하세요." + ) + } +} + +data class TestSchedulerResponse( + val success: Boolean, + val message: String +) diff --git a/src/main/kotlin/codel/question/business/QuestionService.kt b/src/main/kotlin/codel/question/business/QuestionService.kt new file mode 100644 index 00000000..ae2577b9 --- /dev/null +++ b/src/main/kotlin/codel/question/business/QuestionService.kt @@ -0,0 +1,151 @@ +package codel.question.business + +import codel.question.infrastructure.QuestionJpaRepository +import codel.question.domain.Question +import codel.question.domain.QuestionCategory +import codel.chat.domain.ChatRoomQuestion +import codel.chat.infrastructure.ChatRoomQuestionJpaRepository +import codel.chat.infrastructure.ChatRoomJpaRepository +import codel.member.domain.Member +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class QuestionService( + private val questionJpaRepository: QuestionJpaRepository, + private val chatRoomQuestionJpaRepository: ChatRoomQuestionJpaRepository, + private val chatRoomJpaRepository: ChatRoomJpaRepository +) { + + /** + * 모든 활성 질문 조회 + */ + fun findActiveQuestions(): List { + return questionJpaRepository.findActiveQuestions() + } + + /** + * 회원가입용 활성 질문 조회 (IF, BALANCE_ONE 카테고리 제외) + */ + fun findActiveQuestionsForSignup(): List { + return questionJpaRepository.findActiveQuestionsForSignup() + } + + /** + * ID로 질문 조회 + */ + fun findQuestionById(questionId: Long): Question { + return questionJpaRepository.findById(questionId).orElseThrow { + IllegalArgumentException("질문을 찾을 수 없습니다. ID: $questionId") + } + } + + /** + * 채팅방에서 사용하지 않은 질문들 조회 + */ + fun findUnusedQuestionsByChatRoom(chatRoomId: Long): List { + return questionJpaRepository.findUnusedQuestionsByChatRoom(chatRoomId) + } + + /** + * 질문 리스트에서 랜덤 선택 + */ + fun selectRandomQuestion(questions: List): Question { + if (questions.isEmpty()) { + throw IllegalArgumentException("선택할 수 있는 질문이 없습니다.") + } + return questions.random() + } + + /** + * 질문을 사용된 것으로 표시 + */ + @Transactional + fun markQuestionAsUsed(chatRoomId: Long, question: Question, requestedBy: Member) { + val chatRoom = chatRoomJpaRepository.findById(chatRoomId).orElseThrow { + IllegalArgumentException("채팅방을 찾을 수 없습니다.") + } + + val chatRoomQuestion = ChatRoomQuestion.create(chatRoom, question, requestedBy) + chatRoomQuestionJpaRepository.save(chatRoomQuestion) + } + + // ========== 관리자용 메서드들 ========== + + /** + * 필터 조건으로 질문 목록 조회 + */ + fun findQuestionsWithFilter( + keyword: String?, + category: String?, + isActive: Boolean?, + pageable: Pageable + ): Page { + val categoryEnum = if (category.isNullOrBlank()) null else QuestionCategory.valueOf(category) + return questionJpaRepository.findAllWithFilter(keyword, categoryEnum, isActive, pageable) + } + + /** + * 새 질문 생성 + */ + @Transactional + fun createQuestion( + content: String, + category: QuestionCategory, + description: String?, + isActive: Boolean + ): Question { + val question = Question( + content = content, + category = category, + description = description, + isActive = isActive + ) + return questionJpaRepository.save(question) + } + + /** + * 질문 수정 + */ + @Transactional + fun updateQuestion( + questionId: Long, + content: String, + category: QuestionCategory, + description: String?, + isActive: Boolean + ): Question { + val question = findQuestionById(questionId) + + question.updateContent(content) + question.updateCategory(category) + question.updateDescription(description) + question.updateIsActive(isActive) + + return questionJpaRepository.save(question) + } + + /** + * 질문 삭제 + */ + @Transactional + fun deleteQuestion(questionId: Long) { + if (!questionJpaRepository.existsById(questionId)) { + throw IllegalArgumentException("질문을 찾을 수 없습니다. ID: $questionId") + } + questionJpaRepository.deleteById(questionId) + } + + /** + * 질문 상태 토글 + */ + @Transactional + fun toggleQuestionStatus(questionId: Long): Question { + val question = findQuestionById(questionId) + question.toggleActive() + return questionJpaRepository.save(question) + } +} diff --git a/src/main/kotlin/codel/question/domain/Question.kt b/src/main/kotlin/codel/question/domain/Question.kt new file mode 100644 index 00000000..a367ead1 --- /dev/null +++ b/src/main/kotlin/codel/question/domain/Question.kt @@ -0,0 +1,59 @@ +package codel.question.domain + +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* + +@Entity +class Question( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(nullable = false, length = 500) + var content: String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 100) + var category: QuestionCategory, + + @Column(nullable = false) + var isActive: Boolean = true, + + @Column(nullable = true, length = 1000) + var description: String? = null // 질문 설명 +) : BaseTimeEntity() { + + fun getIdOrThrow(): Long = id ?: throw IllegalStateException("질문이 존재하지 않습니다.") + + fun isAvailable(): Boolean = isActive + + fun isSameCategory(other: Question): Boolean = this.category == other.category + + fun updateContent(newContent: String) { + this.content = newContent + } + + fun updateCategory(newCategory: QuestionCategory) { + this.category = newCategory + } + + fun updateDescription(newDescription: String?) { + this.description = newDescription + } + + fun toggleActive() { + this.isActive = !this.isActive + } + + fun updateIsActive(newIsActive: Boolean) { + this.isActive = newIsActive + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Question + return id != null && id == other.id + } + + override fun hashCode(): Int = id?.hashCode() ?: 0 +} diff --git a/src/main/kotlin/codel/question/domain/QuestionCategory.kt b/src/main/kotlin/codel/question/domain/QuestionCategory.kt new file mode 100644 index 00000000..973c50db --- /dev/null +++ b/src/main/kotlin/codel/question/domain/QuestionCategory.kt @@ -0,0 +1,41 @@ +package codel.question.domain + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "질문 카테고리", enumAsRef = true) +enum class QuestionCategory( + @Schema(description = "카테고리 표시명") + val displayName: String, + @Schema(description = "카테고리 상세 설명") + val description: String +) { + @Schema(description = "가치관 관련 질문") + VALUES("가치관", "인생 가치관·성향"), + + @Schema(description = "취향 관련 질문") + FAVORITE("취향", "취향·관심사·콘텐츠"), + + @Schema(description = "현재 상태 관련 질문") + CURRENT_ME("요즘 나", "최근 상태·몰입한 것"), + + @Schema(description = "데이트/관계 관련 질문") + DATE("데이트", "사람 대할 때 나의 방식"), + + @Schema(description = "추억/경험 관련 질문") + MEMORY("추억", "감동·전환점·경험 공유"), + + @Schema(description = "대화 주제 관련 질문") + WANT_TALK("이런 대화 해보고 싶어", "나누고 싶은 진짜 이야기"), + + @Schema(description = "밸런스 게임 관련 질문") + BALANCE_ONE("하나만", "가벼운 밸런스 게임"), + + @Schema(description = "가정 상황 관련 질문") + IF("만약에", "가상의 상황·선택 질문"); + + companion object { + fun fromString(category: String?): QuestionCategory? { + return values().find { it.name.equals(category, ignoreCase = true) } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt b/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt new file mode 100644 index 00000000..832caff2 --- /dev/null +++ b/src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt @@ -0,0 +1,48 @@ +package codel.question.infrastructure + +import codel.question.domain.Question +import codel.question.domain.QuestionCategory +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface QuestionJpaRepository : JpaRepository { + + @Query("SELECT q FROM Question q WHERE q.isActive = true") + fun findActiveQuestions(): List + + @Query(""" + SELECT q FROM Question q + WHERE q.isActive = true + AND q.category NOT IN ('IF', 'BALANCE_ONE') + """) + fun findActiveQuestionsForSignup(): List + + @Query(""" + SELECT q FROM Question q + WHERE q.isActive = true + AND q.id NOT IN ( + SELECT crq.question.id FROM ChatRoomQuestion crq + WHERE crq.chatRoom.id = :chatRoomId AND crq.isUsed = true + ) + """) + fun findUnusedQuestionsByChatRoom(@Param("chatRoomId") chatRoomId: Long): List + + @Query(""" + SELECT q FROM Question q + WHERE (:keyword IS NULL OR :keyword = '' OR q.content LIKE CONCAT('%', :keyword, '%') OR q.description LIKE CONCAT('%', :keyword, '%')) + AND (:category IS NULL OR q.category = :category) + AND (:isActive IS NULL OR q.isActive = :isActive) + ORDER BY q.createdAt DESC + """) + fun findAllWithFilter( + @Param("keyword") keyword: String?, + @Param("category") category: QuestionCategory?, + @Param("isActive") isActive: Boolean?, + pageable: Pageable + ): Page +} diff --git a/src/main/kotlin/codel/question/presentation/QuestionController.kt b/src/main/kotlin/codel/question/presentation/QuestionController.kt new file mode 100644 index 00000000..cd17cee0 --- /dev/null +++ b/src/main/kotlin/codel/question/presentation/QuestionController.kt @@ -0,0 +1,26 @@ +package codel.question.presentation + +import codel.question.presentation.response.QuestionResponse +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.question.business.QuestionService +import codel.question.presentation.swagger.QuestionControllerSwagger +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 + +@RestController +@RequestMapping("/v1/questions") +class QuestionController( + val questionService : QuestionService +) : QuestionControllerSwagger { + + @GetMapping + override fun findActiveQuestion( + @LoginMember member : Member + ) : ResponseEntity>{ + val findActiveQuestions = questionService.findActiveQuestionsForSignup() + return ResponseEntity.ok(findActiveQuestions.map { question -> QuestionResponse.from(question) }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/question/presentation/response/QuestionResponse.kt b/src/main/kotlin/codel/question/presentation/response/QuestionResponse.kt new file mode 100644 index 00000000..1303b519 --- /dev/null +++ b/src/main/kotlin/codel/question/presentation/response/QuestionResponse.kt @@ -0,0 +1,27 @@ +package codel.question.presentation.response + +import codel.question.domain.Question +import codel.question.domain.QuestionCategory +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "질문 응답 정보") +data class QuestionResponse( + @Schema(description = "질문 고유 ID", example = "1") + val questionId: Long, + + @Schema(description = "질문 카테고리", example = "VALUES") + val category : QuestionCategory, + + @Schema(description = "질문 내용", example = "당신의 인생에서 가장 중요한 가치는 무엇인가요?") + val content: String +) { + companion object { + fun from(question: Question): QuestionResponse { + return QuestionResponse( + questionId = question.getIdOrThrow(), + category = question.category, + content = question.content + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/question/presentation/swagger/QuestionControllerSwagger.kt b/src/main/kotlin/codel/question/presentation/swagger/QuestionControllerSwagger.kt new file mode 100644 index 00000000..eb093e01 --- /dev/null +++ b/src/main/kotlin/codel/question/presentation/swagger/QuestionControllerSwagger.kt @@ -0,0 +1,64 @@ +package codel.question.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.question.presentation.response.QuestionResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity + +@Tag(name = "Question", description = "질문 관련 API") +interface QuestionControllerSwagger { + + @Operation( + summary = "활성화된 질문 목록 조회 (회원가입용)", + description = """ + 회원가입 시 선택할 수 있는 활성화된 질문을 카테고리별로 조회합니다. + + **제외되는 카테고리:** + - IF: 만약에 (가상의 상황·선택 질문) - 회원가입 시 적합하지 않음 + - BALANCE_ONE: 하나만 (가벼운 밸런스 게임) - 회원가입 시 적합하지 않음 + + **포함되는 카테고리:** + - VALUES: 가치관 관련 질문 (인생 가치관·성향) + - FAVORITE: 취향 관련 질문 (취향·관심사·콘텐츠) + - CURRENT_ME: 현재 상태 관련 질문 (최근 상태·몰입한 것) + - DATE: 데이트/관계 관련 질문 (사람 대할 때 나의 방식) + - MEMORY: 추억/경험 관련 질문 (감동·전환점·경험 공유) + - WANT_TALK: 대화 주제 관련 질문 (나누고 싶은 진짜 이야기) + + ※ Authorization 헤더에 JWT 토큰을 포함해야 합니다. + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "질문 목록 조회 성공", + content = [Content( + mediaType = "application/json", + array = ArraySchema(schema = Schema(implementation = QuestionResponse::class)) + )] + ), + ApiResponse( + responseCode = "401", + description = "인증되지 않은 사용자 - JWT 토큰이 없거나 유효하지 않음", + content = [Content()] + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = [Content()] + ) + ] + ) + fun findActiveQuestion( + @Parameter(hidden = true) @LoginMember member: Member + ): ResponseEntity> +} diff --git a/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt new file mode 100644 index 00000000..8f498a4e --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/CodeTimeService.kt @@ -0,0 +1,409 @@ +package codel.recommendation.business + +import codel.config.Loggable +import codel.member.domain.Member +import codel.recommendation.domain.CodeTimeRecommendationResult +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationType +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * 코드타임 서비스 + * + * 주요 기능: + * - 시간대별 추천 시스템 (10:00, 22:00) + * - 각 시간대마다 독립적인 추천 결과 + * - 지역 기반 버킷 정책 적용 + * - 시간대별 추천 결과 재사용 (성능 최적화) + * - 중복 방지 정책 적용 + */ +@Service +@Transactional +class CodeTimeService( + private val config: RecommendationConfig, + private val bucketService: RecommendationBucketService, + private val historyService: RecommendationHistoryService, + private val exclusionService: RecommendationExclusionService, + private val timeZoneService: TimeZoneService +) : Loggable { + + /** + * 코드타임 추천을 수행합니다. + * + * 동작: + * - 타임존 기준으로 현재 시간대(10:00 또는 22:00) 확인 + * - 해당 시간대의 유효 기간 내 추천 이력 확인 + * - 이력이 있으면 실시간 필터링 후 반환 + * - 없으면 새로 생성 + * + * @param user 추천을 받을 사용자 + * @param page 페이지 번호 + * @param size 페이지 크기 + * @param timeZoneId 타임존 ID (null이면 기본값 KST 사용) + */ + fun getCodeTimeRecommendation(user: Member, + page : Int, + size : Int, + timeZoneId: String? = null + ): Page { + val timeSlotCalculator = TimeSlotCalculator("ko") + log.info { "코드타임 추천 요청 - userId: ${user.getIdOrThrow()}" } + + // 1. 타임존 기준 현재 시간대 확인 (항상 "10:00" 또는 "22:00" 반환) + val currentTimeSlot = timeZoneService.getCurrentTimeSlot(timeZoneId) + + log.debug { + "현재 시간대: $currentTimeSlot - now: ${timeZoneService.getNow(timeZoneId)}" + } + + // 2. 타임존 기준 현재 시간대의 유효 기간 계산 (UTC로 변환) + val (startDateTime, endDateTime) = timeZoneService.getTimeSlotRangeInUTC(currentTimeSlot, timeZoneId) + + log.debug { + "유효 기간(UTC): $startDateTime ~ $endDateTime" + } + + // 3. 유효 기간 내 추천 이력 확인 + val existingRecommendationIds = historyService.getCodeTimeIdsByTimeRange( + user = user, + timeSlot = currentTimeSlot, + startDateTime = startDateTime, + endDateTime = endDateTime + ) + log.info { "유효 기간 내 추천 이력 확인 : " + existingRecommendationIds.size } + + if (existingRecommendationIds.isNotEmpty()) { + log.info { + "기존 코드타임 결과 존재 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $currentTimeSlot, count: ${existingRecommendationIds.size}개" + } + + // 4. 실시간 필터링: 차단/시그널 등으로 제외해야 할 사용자 제외 + val filteredIds = filterExcludedMembers(user, existingRecommendationIds) + + if (filteredIds.size != existingRecommendationIds.size) { + log.info { + "실시간 필터링 적용 - userId: ${user.getIdOrThrow()}, " + + "before: ${existingRecommendationIds.size}명, after: ${filteredIds.size}명, " + + "filtered: ${existingRecommendationIds.size - filteredIds.size}명" + } + } + + if (filteredIds.isNotEmpty()) { + val members = bucketService.getMembersByIds(filteredIds) + val pageable = PageRequest.of(page, size) + + return PageImpl(members, pageable, members.size.toLong()) + } + + // 모두 필터링되어 비어있으면 새로 생성 + log.info { + "모든 추천이 필터링됨, 새로 생성 - userId: ${user.getIdOrThrow()}" + } + + val pageable = PageRequest.of(page, size) + + return PageImpl(ArrayList(), pageable, 0L) + } + + // 5. 새로운 추천 생성 + val newRecommendations = generateNewCodeTimeRecommendation(user, currentTimeSlot) + + // 6. 추천 이력 저장 + if (newRecommendations.isNotEmpty()) { + historyService.saveRecommendationHistory( + user = user, + recommendedUsers = newRecommendations, + type = RecommendationType.CODE_TIME, + timeSlot = currentTimeSlot, + dateTime = LocalDateTime.now() + ) + } + + log.info { + "새 코드타임 생성 완료 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $currentTimeSlot, count: ${newRecommendations.size}개" + } + + val pageable = PageRequest.of(page, size) + + return PageImpl(newRecommendations, pageable, newRecommendations.size.toLong()) + + } + + /** + * 기존 추천 목록에서 실시간으로 제외해야 할 사용자를 필터링합니다. + * + * 제외 대상: + * - 차단한 사용자 + * - 나를 차단한 사용자 + * - 최근 시그널 보낸 사용자 + * - WITHDRAWN 상태의 사용자 (회원 탈퇴) + * + * @param user 기준 사용자 + * @param memberIds 필터링할 사용자 ID 목록 + * @return 필터링된 사용자 ID 목록 + */ + private fun filterExcludedMembers(user: Member, memberIds: List): List { + if (memberIds.isEmpty()) { + return emptyList() + } + + val excludeIds = mutableSetOf() + excludeIds.addAll(exclusionService.getBlockedMemberIds(user)) + excludeIds.addAll(exclusionService.getRecentSignalMemberIds(user)) + + // WITHDRAWN 상태의 회원 필터링 + // getMembersByIds를 통해 조회하면 자동으로 WITHDRAWN이 제외됨 + val validMembers = bucketService.getMembersByIds(memberIds) + val validIds = validMembers.map { it.getIdOrThrow() } + + val filteredIds = validIds.filter { it !in excludeIds } + + log.debug { + "실시간 필터링 - userId: ${user.getIdOrThrow()}, " + + "original: ${memberIds.size}명, excluded: ${excludeIds.size}명, " + + "withdrawn: ${memberIds.size - validIds.size}명, " + + "result: ${filteredIds.size}명" + } + + return filteredIds + } + + /** + * 특정 시간대의 코드타임 추천을 조회합니다. + */ + fun getCodeTimeRecommendationBySlot( + user: Member, + timeSlot: String, + date: LocalDate = LocalDate.now(), + timeZoneId: String? = null + ): CodeTimeRecommendationResult { + val timeSlotCalculator = TimeSlotCalculator("ko") + log.info { + "특정 시간대 코드타임 조회 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, date: $date" + } + + // 유효한 시간대인지 확인 + if (timeSlot !in config.codeTimeSlots) { + log.warn { + "유효하지 않은 시간대 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, validSlots: ${config.codeTimeSlots}" + } + return CodeTimeRecommendationResult( + timeSlot = timeSlot, + members = emptyList(), + isActiveTime = false, + nextTimeSlot = getNextTimeSlot(timeZoneId) + ) + } + + // 해당 시간대 추천 결과 조회 + val today = timeZoneService.getToday(timeZoneId) + val recommendationIds = if (date == today) { + // 타임존 기준 오늘이면 시간 범위로 조회 + val (startDateTime, endDateTime) = timeZoneService.getTimeSlotRangeInUTC(timeSlot, timeZoneId) + historyService.getCodeTimeIdsByTimeRange(user, timeSlot, startDateTime, endDateTime) + } else { + emptyList() + } + + val members = if (recommendationIds.isNotEmpty()) { + bucketService.getMembersByIds(recommendationIds) + } else { + emptyList() + } + + val isCurrentTimeSlot = getCurrentTimeSlot(timeZoneId) == timeSlot + + return CodeTimeRecommendationResult( + timeSlot = timeSlot, + members = members, + isActiveTime = isCurrentTimeSlot, + nextTimeSlot = getNextTimeSlot(timeZoneId) + ) + } + + private fun generateNewCodeTimeRecommendation(user: Member, timeSlot: String): List { + val userProfile = user.profile + if (userProfile == null) { + log.warn { "사용자 프로필이 없습니다 - userId: ${user.getIdOrThrow()}" } + return emptyList() + } + + val userMainRegion = userProfile.bigCity + val userSubRegion = userProfile.smallCity + + if (userMainRegion.isNullOrBlank()) { + log.warn { "사용자 지역 정보가 없습니다 - userId: ${user.getIdOrThrow()}" } + return emptyList() + } + + val excludeIds = getExcludeIdsForCodeTime(user) + + log.debug { + "코드타임 생성 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, region: $userMainRegion-$userSubRegion, " + + "excludeCount: ${excludeIds.size}개" + } + + val candidates = bucketService.getCandidatesByBucket( + userMainRegion = userMainRegion, + userSubRegion = userSubRegion ?: "", + excludeIds = excludeIds, + requiredCount = config.codeTimeCount + ) + + log.info { + "코드타임 후보자 선정 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, requested: ${config.codeTimeCount}개, actual: ${candidates.size}개" + } + + return candidates + } + + private fun getExcludeIdsForCodeTime(user: Member): Set { + return exclusionService.getAllExcludedIds(user, RecommendationType.CODE_TIME) + } + + /** + * 타임존 기준으로 현재 활성 시간대를 반환합니다. + * + * @param timeZoneId 타임존 ID (null이면 기본값 KST 사용) + * @return 현재 활성 시간대 ("10:00" 또는 "22:00") + */ + fun getCurrentTimeSlot(timeZoneId: String? = null): String { + return timeZoneService.getCurrentTimeSlot(timeZoneId) + } + + fun getNextTimeSlot(timeZoneId: String? = null): String? { + val zone = timeZoneService.getTimeZone(timeZoneId) + val now = java.time.LocalDateTime.now(zone) + val currentHour = now.hour + + return when { + currentHour < 10 -> "10:00" + currentHour < 22 -> "22:00" + else -> "10:00" // 다음날 10:00 + } + } + + fun hasCodeTimeHistory( + user: Member, + timeSlot: String, + date: LocalDate = LocalDate.now() + ): Boolean { + return historyService.hasCodeTimeHistory(user, timeSlot, date) + } + + fun forceRefreshCodeTime(user: Member, timeSlot: String, timeZoneId: String? = null): CodeTimeRecommendationResult { + log.info { + "코드타임 강제 새로고침 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot" + } + + if (timeSlot !in config.codeTimeSlots) { + log.warn { + "유효하지 않은 시간대로 강제 새로고침 시도 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, validSlots: ${config.codeTimeSlots}" + } + return CodeTimeRecommendationResult.createEmptyActiveResult(timeSlot, getNextTimeSlot(timeZoneId)) + } + + val newRecommendations = generateNewCodeTimeRecommendation(user, timeSlot) + + if (newRecommendations.isNotEmpty()) { + historyService.saveRecommendationHistory( + user = user, + recommendedUsers = newRecommendations, + type = RecommendationType.CODE_TIME, + timeSlot = timeSlot, + dateTime = LocalDateTime.now() + ) + } + + log.info { + "코드타임 강제 새로고침 완료 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, count: ${newRecommendations.size}개" + } + + return CodeTimeRecommendationResult( + timeSlot = timeSlot, + members = newRecommendations, + isActiveTime = getCurrentTimeSlot(timeZoneId) == timeSlot, + nextTimeSlot = getNextTimeSlot(timeZoneId) + ) + } + + fun getCodeTimeStats(user: Member, timeZoneId: String? = null): Map { + val stats = mutableMapOf() + + val currentTimeSlot = getCurrentTimeSlot(timeZoneId) + stats["currentTimeSlot"] = currentTimeSlot + stats["isActiveTime"] = true + stats["nextTimeSlot"] = getNextTimeSlot(timeZoneId) ?: "" + + stats["configuredTimeSlots"] = config.codeTimeSlots + stats["targetCountPerSlot"] = config.codeTimeCount + + val slotStats = mutableMapOf>() + for (timeSlot in config.codeTimeSlots) { + val hasHistory = hasCodeTimeHistory(user, timeSlot) + + // 타임존 기준으로 오늘인지 확인 + val today = timeZoneService.getToday(timeZoneId) + val recommendationIds = if (today == LocalDate.now()) { + val (startDateTime, endDateTime) = timeZoneService.getTimeSlotRangeInUTC(timeSlot, timeZoneId) + historyService.getCodeTimeIdsByTimeRange(user, timeSlot, startDateTime, endDateTime) + } else { + emptyList() + } + + slotStats[timeSlot] = mapOf( + "hasHistory" to hasHistory, + "recommendationCount" to recommendationIds.size, + "isCurrentSlot" to (currentTimeSlot == timeSlot) + ) + } + stats["slotStatistics"] = slotStats + + stats["totalUniqueCount"] = historyService.getTotalUniqueRecommendedCount(user) + + val userProfile = user.profile + if (userProfile != null) { + stats["userRegion"] = "${userProfile.bigCity ?: "미설정"}-${userProfile.smallCity ?: "미설정"}" + } else { + stats["userRegion"] = "미설정" + } + + log.debug { + "코드타임 통계 조회 - userId: ${user.getIdOrThrow()}, " + + "stats: $stats" + } + + return stats + } + + fun getAllTodayCodeTimeResults(user: Member): Map { + val results = mutableMapOf() + + for (timeSlot in config.codeTimeSlots) { + results[timeSlot] = getCodeTimeRecommendationBySlot(user, timeSlot) + } + + log.debug { + "전체 코드타임 결과 조회 - userId: ${user.getIdOrThrow()}, " + + "slotsCount: ${results.size}" + } + + return results + } +} diff --git a/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt b/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt new file mode 100644 index 00000000..9d9eac8c --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/DailyCodeMatchingService.kt @@ -0,0 +1,299 @@ +package codel.recommendation.business + +import codel.config.Loggable +import codel.member.domain.Member +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 오늘의 코드매칭 서비스 + * + * 주요 기능: + * - 24시간 유지되는 일일 추천 시스템 + * - 지역 기반 버킷 정책 적용 + * - 기존 추천 결과 재사용 (성능 최적화) + * - 중복 방지 정책 적용 + */ +@Service +@Transactional +class DailyCodeMatchingService( + private val config: RecommendationConfig, + private val bucketService: RecommendationBucketService, + private val historyService: RecommendationHistoryService, + private val exclusionService: RecommendationExclusionService, + private val timeZoneService: TimeZoneService +) : Loggable { + + /** + * 오늘의 코드매칭 추천을 수행합니다. + * + * 동작 원리: + * 1. 사용자 타임존 기준 오늘 추천 이력 확인 (24시간 유지) + * 2. 기존 이력이 있으면 실시간 필터링 후 반환 (차단/시그널 체크) + * 3. 없으면 새로 생성 (버킷 정책 + 중복 방지) + * 4. 생성된 추천 결과를 이력에 저장 + * + * @param user 추천을 받을 사용자 + * @return 추천된 사용자 목록 (Member 객체) + */ + fun getDailyCodeMatching(user: Member): List { + log.info { "오늘의 코드매칭 요청 - userId: ${user.getIdOrThrow()}" } + + // 1. 사용자 타임존 기준 오늘 추천 결과 확인 + val existingRecommendationIds = historyService.getTodayDailyCodeMatchingIds(user) + + if (existingRecommendationIds.isNotEmpty()) { + log.info { + "기존 오늘의 코드매칭 결과 존재 - userId: ${user.getIdOrThrow()}, " + + "count: ${existingRecommendationIds.size}개" + } + + // 2. 실시간 필터링: 차단/시그널 등으로 제외해야 할 사용자 제외 + val filteredIds = filterExcludedMembers(user, existingRecommendationIds) + + if (filteredIds.size != existingRecommendationIds.size) { + log.info { + "실시간 필터링 적용 - userId: ${user.getIdOrThrow()}, " + + "before: ${existingRecommendationIds.size}명, after: ${filteredIds.size}명, " + + "filtered: ${existingRecommendationIds.size - filteredIds.size}명" + } + } + + // 3. 필터링된 결과 반환 + if (filteredIds.isNotEmpty()) { + return bucketService.getMembersByIds(filteredIds) + } + + // 4. 모두 필터링되어 비어있으면 새로 생성 + log.info { + "모든 추천이 필터링됨, 새로 생성 - userId: ${user.getIdOrThrow()}" + } + return emptyList() + } + + // 5. 새로운 추천 생성 + val newRecommendations = generateNewDailyCodeMatching(user) + + // 6. 추천 이력 저장 + if (newRecommendations.isNotEmpty()) { + historyService.saveRecommendationHistory( + user = user, + recommendedUsers = newRecommendations, + type = RecommendationType.DAILY_CODE_MATCHING, + timeSlot = null, + dateTime = LocalDateTime.now() + ) + } + + log.info { + "새 오늘의 코드매칭 생성 완료 - userId: ${user.getIdOrThrow()}, " + + "count: ${newRecommendations.size}개" + } + + return newRecommendations + } + + /** + * 기존 추천 목록에서 실시간으로 제외해야 할 사용자를 필터링합니다. + * + * 제외 대상: + * - 차단한 사용자 + * - 나를 차단한 사용자 + * - 최근 시그널 보낸 사용자 + * - WITHDRAWN 상태의 사용자 (회원 탈퇴) + * + * @param user 기준 사용자 + * @param memberIds 필터링할 사용자 ID 목록 + * @return 필터링된 사용자 ID 목록 + */ + private fun filterExcludedMembers(user: Member, memberIds: List): List { + if (memberIds.isEmpty()) { + return emptyList() + } + + // 실시간 제외 대상 조회 (차단 + 시그널만) + val excludeIds = mutableSetOf() + + // 1. 차단 관계 + excludeIds.addAll(exclusionService.getBlockedMemberIds(user)) + + // 2. WITHDRAWN 상태의 회원 필터링 + // getMembersByIds를 통해 조회하면 자동으로 WITHDRAWN이 제외되므로 + // 여기서는 ID만 필터링 + val validMembers = bucketService.getMembersByIds(memberIds) + val validIds = validMembers.map { it.getIdOrThrow() } + + // 3. 최종 필터링 + val filteredIds = validIds.filter { it !in excludeIds } + + log.debug { + "실시간 필터링 - userId: ${user.getIdOrThrow()}, " + + "original: ${memberIds.size}명, excluded: ${excludeIds.size}명, " + + "withdrawn: ${memberIds.size - validIds.size}명, " + + "result: ${filteredIds.size}명" + } + + return filteredIds + } + + /** + * 새로운 오늘의 코드매칭 추천을 생성합니다. + * + * @param user 추천을 받을 사용자 + * @return 새로 생성된 추천 사용자 목록 + */ + private fun generateNewDailyCodeMatching(user: Member): List { + // 1. 사용자 지역 정보 확인 + val userProfile = user.profile + if (userProfile == null) { + log.warn { "사용자 프로필이 없습니다 - userId: ${user.getIdOrThrow()}" } + return emptyList() + } + + val userMainRegion = userProfile.bigCity + val userSubRegion = userProfile.smallCity + + if (userMainRegion.isNullOrBlank()) { + log.warn { "사용자 지역 정보가 없습니다 - userId: ${user.getIdOrThrow()}" } + return emptyList() + } + + // 2. 중복 방지 대상 조회 + val excludeIds = getExcludeIdsForDailyCodeMatching(user) + + log.info { "코드매칭 중 제외된 아이디 전부 조회 :::: " + excludeIds.joinToString(",") } + + log.debug { + "오늘의 코드매칭 생성 - userId: ${user.getIdOrThrow()}, " + + "region: $userMainRegion-$userSubRegion, excludeCount: ${excludeIds.size}개" + } + + // 3. 버킷 정책으로 후보자 조회 + val candidates = bucketService.getCandidatesByBucket( + userMainRegion = userMainRegion, + userSubRegion = userSubRegion ?: "", + excludeIds = excludeIds, + requiredCount = config.dailyCodeCount + ) + + log.info { + "오늘의 코드매칭 후보자 선정 - userId: ${user.getIdOrThrow()}, " + + "requested: ${config.dailyCodeCount}개, actual: ${candidates.size}개" + } + + return candidates + } + + /** + * 오늘의 코드매칭에서 제외해야 할 사용자 ID를 조회합니다. + * + * 제외 대상: + * - 본인 + * - 추천 이력 중복 방지 (N일 내) + * - 차단 관계 (내가 차단 + 나를 차단) + * - 최근 시그널 관계 (7일 내) + * + * @param user 기준이 되는 사용자 + * @return 제외해야 할 사용자 ID Set (본인 포함) + */ + private fun getExcludeIdsForDailyCodeMatching(user: Member): Set { + return exclusionService.getAllExcludedIds(user, RecommendationType.DAILY_CODE_MATCHING) + } + + /** + * 오늘의 코드매칭 이력이 있는지 확인합니다. + * 타임존 기준으로 "오늘"을 판단합니다. + * + * @param user 확인할 사용자 + * @param date 확인할 날짜 (타임존 기준, 기본값: 오늘) + * @param timeZoneId 타임존 ID (null이면 기본값 KST 사용) + * @return 해당 날짜에 오늘의 코드매칭 이력이 있는지 여부 + */ + fun hasTodayDailyCodeMatching(user: Member, date: LocalDate? = null, timeZoneId: String? = null): Boolean { + val targetDate = date ?: timeZoneService.getToday(timeZoneId) + val userToday = timeZoneService.getToday(timeZoneId) + + // 오늘이 아닌 날짜는 false 반환 + if (targetDate != userToday) { + return false + } + + // 오늘이면 실제 이력 조회 + val ids = historyService.getTodayDailyCodeMatchingIds(user, timeZoneId) + return ids.isNotEmpty() + } + + /** + * 오늘의 코드매칭을 강제로 새로 생성합니다. + * 관리자용 기능이나 테스트 목적으로 사용합니다. + * + * @param user 추천을 받을 사용자 + * @return 새로 생성된 추천 사용자 목록 + */ + fun forceRefreshDailyCodeMatching(user: Member): List { + log.info { "오늘의 코드매칭 강제 새로고침 - userId: ${user.getIdOrThrow()}" } + + val newRecommendations = generateNewDailyCodeMatching(user) + + if (newRecommendations.isNotEmpty()) { + historyService.saveRecommendationHistory( + user = user, + recommendedUsers = newRecommendations, + type = RecommendationType.DAILY_CODE_MATCHING, + timeSlot = null, + dateTime = LocalDateTime.now() + ) + } + + log.info { + "오늘의 코드매칭 강제 새로고침 완료 - userId: ${user.getIdOrThrow()}, " + + "count: ${newRecommendations.size}개" + } + + return newRecommendations + } + + /** + * 오늘의 코드매칭 통계 정보를 조회합니다. + * 모니터링 및 분석 목적으로 사용합니다. + * + * @param user 통계를 조회할 사용자 + * @return 통계 정보 맵 + */ + fun getDailyCodeMatchingStats(user: Member): Map { + val stats = mutableMapOf() + + // 1. 오늘 추천 여부 + val hasToday = hasTodayDailyCodeMatching(user) + stats["hasToday"] = hasToday + + // 2. 오늘 추천된 사용자 수 + val todayRecommendationIds = historyService.getTodayDailyCodeMatchingIds(user) + stats["todayCount"] = todayRecommendationIds.size + + // 3. 설정된 추천 목표 수 + stats["targetCount"] = config.dailyCodeCount + + // 4. 총 추천받은 고유 사용자 수 + stats["totalUniqueCount"] = historyService.getTotalUniqueRecommendedCount(user) + + // 5. 사용자 지역 정보 + val userProfile = user.profile + if (userProfile != null) { + stats["userRegion"] = "${userProfile.bigCity ?: "미설정"}-${userProfile.smallCity ?: "미설정"}" + } else { + stats["userRegion"] = "미설정" + } + + log.debug { + "오늘의 코드매칭 통계 조회 - userId: ${user.getIdOrThrow()}, " + + "stats: $stats" + } + + return stats + } +} diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt new file mode 100644 index 00000000..bbb338ed --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/RecommendationBucketService.kt @@ -0,0 +1,239 @@ +package codel.recommendation.business + +import codel.member.domain.Member +import codel.member.infrastructure.MemberJpaRepository +import codel.recommendation.domain.RecommendationConfig +import org.springframework.stereotype.Service +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.transaction.annotation.Transactional +import org.hibernate.Hibernate + +/** + * 추천 시스템의 4단계 버킷 정책을 구현하는 서비스 + * + * 버킷 정책 우선순위: + * - B1: 동일 subRegion (최우선) + * - B2: 동일 mainRegion 내 다른 subRegion + * - B3: 인접 mainRegion + * - B4: 전국 범위 (최후 보충) + */ +@Service +@Transactional +class RecommendationBucketService( + private val memberJpaRepository: MemberJpaRepository, + private val recommendationConfig: RecommendationConfig +) { + + private val logger = KotlinLogging.logger {} + + /** + * 4단계 버킷 정책에 따라 추천 후보자들을 추출합니다. + * 상위 버킷에서 부족한 인원을 하위 버킷에서 보충합니다. + * + * @param userMainRegion 사용자의 메인 지역 (예: 서울, 경기) + * @param userSubRegion 사용자의 서브 지역 (예: 강남, 분당) + * @param excludeIds 제외할 사용자 ID 목록 (자신, 차단, 중복 방지 대상) + * @param requiredCount 필요한 추천 인원수 + * @return 버킷 정책에 따라 정렬된 추천 후보자 목록 + */ + fun getCandidatesByBucket( + userMainRegion: String, + userSubRegion: String, + excludeIds: Set, + requiredCount: Int + ): List { + + logger.info { "버킷 정책 시작 - userRegion: $userMainRegion-$userSubRegion, excludeIds: ${excludeIds.size}개, requiredCount: $requiredCount" } + + val results = mutableListOf() + val usedIds = mutableSetOf() + usedIds.addAll(excludeIds) + + // B1: 동일 subRegion (최우선) + if (results.size < requiredCount) { + val b1Candidates = getBucket1Candidates(userMainRegion, userSubRegion, usedIds) + val b1Selected = b1Candidates.take(requiredCount - results.size) + results.addAll(b1Selected) + usedIds.addAll(b1Selected.mapNotNull { it.id }) + + logger.info { "B1 버킷 (동일 subRegion): ${b1Selected.size}명 선택 (전체 후보: ${b1Candidates.size}명)" } + } + + // B2: 동일 mainRegion 내 다른 subRegion + if (results.size < requiredCount) { + val b2Candidates = getBucket2Candidates(userMainRegion, userSubRegion, usedIds) + val b2Selected = b2Candidates.take(requiredCount - results.size) + results.addAll(b2Selected) + usedIds.addAll(b2Selected.mapNotNull { it.id }) + + logger.info { "B2 버킷 (동일 mainRegion): ${b2Selected.size}명 선택 (전체 후보: ${b2Candidates.size}명)" } + } + + // B3: 인접 mainRegion + if (results.size < requiredCount) { + val b3Candidates = getBucket3Candidates(userMainRegion, usedIds) + val b3Selected = b3Candidates.take(requiredCount - results.size) + results.addAll(b3Selected) + usedIds.addAll(b3Selected.mapNotNull { it.id }) + + logger.info { "B3 버킷 (인접 mainRegion): ${b3Selected.size}명 선택 (전체 후보: ${b3Candidates.size}명)" } + } + + // B4: 전국 범위 (최후 보충) + if (results.size < requiredCount) { + val b4Candidates = getBucket4Candidates(usedIds) + val b4Selected = b4Candidates.take(requiredCount - results.size) + results.addAll(b4Selected) + + logger.info { "B4 버킷 (전국 범위): ${b4Selected.size}명 선택 (전체 후보: ${b4Candidates.size}명)" } + } + + logger.info { "버킷 정책 완료 - 최종 선택: ${results.size}명 / 요청: ${requiredCount}명" } + + return results + } + + /** + * B1 버킷: 동일한 subRegion을 가진 후보자들 조회 + * 가장 가까운 지역으로 최우선 추천 + */ + private fun getBucket1Candidates( + mainRegion: String, + subRegion: String, + excludeIds: Set + ): List { + return memberJpaRepository.findByMainRegionAndSubRegionAndStatusDone( + mainRegion = mainRegion, + subRegion = subRegion, + excludeIds = excludeIds.ifEmpty { setOf(0L) } // 빈 Set은 쿼리에서 문제가 될 수 있음 + ) + } + + /** + * B2 버킷: 동일한 mainRegion이지만 다른 subRegion을 가진 후보자들 조회 + * 같은 광역시/도 내에서 확장 추천 + */ + private fun getBucket2Candidates( + mainRegion: String, + excludeSubRegion: String, + excludeIds: Set + ): List { + return memberJpaRepository.findByMainRegionAndNotSubRegionAndStatusDone( + mainRegion = mainRegion, + excludeSubRegion = excludeSubRegion, + excludeIds = excludeIds.ifEmpty { setOf(0L) } + ) + } + + /** + * B3 버킷: 인접한 mainRegion들의 후보자들 조회 + * 지리적으로 인접한 지역으로 확장 추천 + */ + private fun getBucket3Candidates( + userMainRegion: String, + excludeIds: Set + ): List { + val adjacentRegions = RecommendationConfig.getAdjacentRegions(userMainRegion) + + return if (adjacentRegions.isNotEmpty()) { + memberJpaRepository.findByAdjacentMainRegionsAndStatusDone( + adjacentRegions = adjacentRegions, + excludeIds = excludeIds.ifEmpty { setOf(0L) } + ) + } else { + logger.warn { "인접 지역이 없는 mainRegion: $userMainRegion (제주도 등)" } + emptyList() + } + } + + /** + * B4 버킷: 전국 범위에서 랜덤하게 후보자들 조회 + * 최후 보충용으로 공정한 랜덤 추천 + */ + private fun getBucket4Candidates(excludeIds: Set): List { + return memberJpaRepository.findByStatusDoneExcludingIds( + excludeIds = excludeIds.ifEmpty { setOf(0L) } + ) + } + + /** + * 버킷별 후보자 수를 미리 확인하는 유틸리티 메서드 + * 디버깅 및 모니터링 용도 + */ + fun getBucketStatistics( + userMainRegion: String, + userSubRegion: String, + excludeIds: Set + ): Map { + val stats = mutableMapOf() + val usedIds = excludeIds.toMutableSet() + + // B1 통계 + val b1Count = getBucket1Candidates(userMainRegion, userSubRegion, usedIds).size + stats["B1_동일subRegion"] = b1Count + + // B2 통계 + val b2Count = getBucket2Candidates(userMainRegion, userSubRegion, usedIds).size + stats["B2_동일mainRegion"] = b2Count + + // B3 통계 + val b3Count = getBucket3Candidates(userMainRegion, usedIds).size + stats["B3_인접mainRegion"] = b3Count + + // B4 통계 (샘플링으로 추정) + val b4Sample = getBucket4Candidates(usedIds).take(100) + stats["B4_전국범위_샘플"] = b4Sample.size + + return stats + } + + /** + * ID 목록으로부터 Member 객체들을 조회합니다. + * 추천 이력에서 실제 Member 객체를 가져올 때 사용합니다. + * + * WITHDRAWN 상태의 회원은 자동으로 필터링됩니다. + * + * @param memberIds 조회할 Member ID 목록 + * @return ID 순서대로 정렬된 Member 목록 (존재하지 않는 ID 및 WITHDRAWN 회원 제외) + */ + fun getMembersByIds(memberIds: List): List { + if (memberIds.isEmpty()) { + return emptyList() + } + + // ID 목록으로 Member들 조회 + val members = memberJpaRepository.findAllByIdsWithProfileAndQuestion(memberIds) + + // Lazy Loading 강제 초기화 (Hibernate.initialize 사용) + members.forEach { member -> + Hibernate.initialize(member.profile) + member.profile?.let { profile -> + Hibernate.initialize(profile.representativeQuestion) + } + } + + val membersMap = members.associateBy { it.getIdOrThrow() } + + // 원본 순서 유지하면서 존재하는 Member만 반환 (WITHDRAWN 회원 제외) + val result = memberIds.mapNotNull { id -> + membersMap[id]?.takeIf { !it.isWithdrawn() } + } + + val withdrawnCount = memberIds.size - members.size - (members.size - result.size) + + if (result.size != memberIds.size) { + logger.warn { + "일부 Member가 필터링됨 - requested: ${memberIds.size}개, " + + "found: ${result.size}개, withdrawn: ${withdrawnCount}개, " + + "missing: ${memberIds - result.map { it.getIdOrThrow() }.toSet()}" + } + } + + logger.debug { + "Member ID 목록 조회 완료 - requested: ${memberIds.size}개, " + + "found: ${result.size}개, withdrawn filtered: ${withdrawnCount}개" + } + + return result + } +} diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt new file mode 100644 index 00000000..868a61d7 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/RecommendationConfigService.kt @@ -0,0 +1,164 @@ +package codel.recommendation.business + +import codel.config.Loggable +import codel.recommendation.domain.RecommendationConfigEntity +import codel.recommendation.infrastructure.RecommendationConfigRepository +import jakarta.annotation.PostConstruct +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 추천 시스템 설정 관리 Service + * + * DB에서 설정을 조회하고 런타임에 변경 가능하도록 관리 + * 캐싱을 통해 성능 최적화 + */ +@Service +@Transactional(readOnly = true) +class RecommendationConfigService( + private val configRepository: RecommendationConfigRepository +) : Loggable { + + /** + * 애플리케이션 시작 시 기본 설정 초기화 + */ + @PostConstruct + @Transactional + fun initializeDefaultConfig() { + try { + val existingConfig = configRepository.findTopByOrderByIdAsc() + + if (existingConfig == null) { + val defaultConfig = RecommendationConfigEntity.createDefault() + configRepository.save(defaultConfig) + log.info { "추천 시스템 기본 설정 초기화 완료" } + } else { + log.info { "추천 시스템 설정 로드 완료 - dailyCodeCount: ${existingConfig.dailyCodeCount}, codeTimeCount: ${existingConfig.codeTimeCount}" } + } + } catch (e: Exception) { + log.warn(e) { "추천 시스템 설정 초기화 실패 - 기본값으로 진행" } + } + } + + /** + * 현재 설정 조회 (캐시됨) + */ + @Cacheable(value = ["recommendationConfig"], key = "'singleton'") + fun getConfig(): RecommendationConfigEntity { + return try { + configRepository.findTopByOrderByIdAsc() + ?: RecommendationConfigEntity.createDefault().also { + log.warn { "설정을 찾을 수 없어 기본값 반환" } + } + } catch (e: Exception) { + log.warn(e) { "설정 조회 실패 - 기본값 반환" } + RecommendationConfigEntity.createDefault() + } + } + + /** + * 오늘의 코드매칭 추천 인원 수 조회 + */ + fun getDailyCodeCount(): Int { + return getConfig().dailyCodeCount + } + + /** + * 코드타임 추천 인원 수 조회 + */ + fun getCodeTimeCount(): Int { + return getConfig().codeTimeCount + } + + /** + * 코드타임 시간대 목록 조회 + */ + fun getCodeTimeSlots(): List { + return getConfig().getCodeTimeSlotsAsList() + } + + /** + * 오늘의 코드매칭 갱신 시점 조회 + */ + fun getDailyRefreshTime(): String { + return getConfig().dailyRefreshTime + } + + /** + * 중복 방지 기간 조회 + */ + fun getRepeatAvoidDays(): Int { + return getConfig().repeatAvoidDays + } + + /** + * 중복 허용 여부 조회 + */ + fun getAllowDuplicate(): Boolean { + return getConfig().allowDuplicate + } + + /** + * 설정 업데이트 (캐시 제거) + */ + @Transactional + @CacheEvict(value = ["recommendationConfig"], key = "'singleton'") + fun updateConfig( + dailyCodeCount: Int? = null, + codeTimeCount: Int? = null, + codeTimeSlots: List? = null, + dailyRefreshTime: String? = null, + repeatAvoidDays: Int? = null, + allowDuplicate: Boolean? = null + ): RecommendationConfigEntity { + val config = configRepository.findTopByOrderByIdAsc() + ?: RecommendationConfigEntity.createDefault().also { configRepository.save(it) } + + dailyCodeCount?.let { + require(it > 0) { "dailyCodeCount는 0보다 커야 합니다" } + config.dailyCodeCount = it + } + codeTimeCount?.let { + require(it > 0) { "codeTimeCount는 0보다 커야 합니다" } + config.codeTimeCount = it + } + codeTimeSlots?.let { + require(it.isNotEmpty()) { "codeTimeSlots는 비어있을 수 없습니다" } + config.setCodeTimeSlotsFromList(it) + } + dailyRefreshTime?.let { + require(it.matches(Regex("^([01]?[0-9]|2[0-3]):[0-5][0-9]$"))) { + "잘못된 시간 형식: $it" + } + config.dailyRefreshTime = it + } + repeatAvoidDays?.let { + require(it >= 0) { "repeatAvoidDays는 0 이상이어야 합니다" } + config.repeatAvoidDays = it + } + allowDuplicate?.let { config.allowDuplicate = it } + + val updated = configRepository.save(config) + log.info { "추천 시스템 설정 업데이트 완료" } + + return updated + } + + /** + * 설정을 Map으로 반환 (API 응답용) + */ + fun getConfigAsMap(): Map { + val config = getConfig() + return mapOf( + "dailyCodeCount" to config.dailyCodeCount, + "codeTimeCount" to config.codeTimeCount, + "codeTimeSlots" to config.getCodeTimeSlotsAsList(), + "dailyRefreshTime" to config.dailyRefreshTime, + "repeatAvoidDays" to config.repeatAvoidDays, + "allowDuplicate" to config.allowDuplicate + ) + } +} diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationExclusionService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationExclusionService.kt new file mode 100644 index 00000000..2107875e --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/RecommendationExclusionService.kt @@ -0,0 +1,259 @@ +package codel.recommendation.business + +import codel.block.infrastructure.BlockMemberRelationJpaRepository +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.member.domain.DailySeedProvider +import codel.member.domain.Member +import codel.member.infrastructure.MemberJpaRepository +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationType +import codel.signal.infrastructure.SignalJpaRepository +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +/** + * 추천 시스템의 제외 로직을 전담하는 서비스 + * + * 제외 대상: + * 1. 본인 + * 2. 추천 이력 기반 중복 방지 (N일 내 추천받은 사용자) + * 3. 차단 관계 (내가 차단 + 나를 차단) + * 4. 최근 시그널 관계 (7일 내 시그널 주고받음) + * 5. 채팅방 관계 (현재 또는 과거 채팅방에서 만난 사용자) + */ +@Service +class RecommendationExclusionService( + private val signalJpaRepository: SignalJpaRepository, + private val blockMemberRelationJpaRepository: BlockMemberRelationJpaRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val memberJpaRepository: MemberJpaRepository, + private val historyService: RecommendationHistoryService, + private val config: RecommendationConfig +) { + + private val logger = KotlinLogging.logger {} + + /** + * 추천에서 제외해야 할 모든 사용자 ID를 반환합니다. + * + * @param user 기준 사용자 + * @param type 추천 타입 (DAILY_CODE_MATCHING or CODE_TIME) + * @return 제외해야 할 사용자 ID Set + */ + fun getAllExcludedIds( + user: Member, + type: RecommendationType + ): Set { + val excludeIds = mutableSetOf() + + // 1. 본인 제외 + val userId = user.getIdOrThrow() + excludeIds.add(userId) + logger.debug { "제외 - 본인: [$userId]" } + + // 2. 추천 이력 기반 중복 방지 + val historyExcludeIds = if (config.allowDuplicate) { + historyService.getExcludedUserIds(user) + } else { + historyService.getExcludedUserIdsByType(user, type) + } + excludeIds.addAll(historyExcludeIds) + logger.debug { + "제외 - ${config.repeatAvoidDays}일 내 추천 이력: $historyExcludeIds (${historyExcludeIds.size}명)" + } + + // 3. 차단 관계 + val blockedIds = getBlockedMemberIds(user) + excludeIds.addAll(blockedIds) + logger.debug { + "제외 - 차단 관계: $blockedIds (${blockedIds.size}명)" + } + + // 4. 최근 시그널 관계 + val signalIds = getRecentSignalMemberIds(user) + excludeIds.addAll(signalIds) + logger.debug { + "제외 - 7일 내 시그널: $signalIds (${signalIds.size}명)" + } + + // 5. 채팅방 관계 (새로 추가) + val chatRoomIds = getChatRoomMemberIds(user) + excludeIds.addAll(chatRoomIds) + logger.debug { + "제외 - 채팅방 관계: $chatRoomIds (${chatRoomIds.size}명)" + } + + logger.info { + "제외 대상 조회 완료 - userId: ${user.getIdOrThrow()}, " + + "type: $type, 전체 제외: ${excludeIds.size}개 " + + "(본인:1, history:${historyExcludeIds.size}, " + + "blocked:${blockedIds.size}, signal:${signalIds.size}, " + + "chatroom:${chatRoomIds.size}), " + + "제외 ID 목록: $excludeIds" + } + + return excludeIds + } + + /** + * 차단 관계에 있는 사용자 ID를 반환합니다. + * - 내가 차단한 사용자 + * - 나를 차단한 사용자 + * + * @param user 기준 사용자 + * @return 차단 관계 사용자 ID Set + */ + fun getBlockedMemberIds(user: Member): Set { + val userId = user.getIdOrThrow() + + // 내가 차단한 사용자들 + val blockedByMe = blockMemberRelationJpaRepository + .findBlockMembersBy(userId) + .mapNotNull { it.blockedMember.id } + + // 나를 차단한 사용자들 + val blockingMe = blockMemberRelationJpaRepository + .findBlockerMembersTo(userId) + .mapNotNull { it.blockerMember.id } + + val result = (blockedByMe + blockingMe).toSet() + + logger.debug { + "차단 관계 조회 - userId: $userId, " + + "blockedByMe: ${blockedByMe.size}명, blockingMe: ${blockingMe.size}명, " + + "total: ${result.size}명" + } + + return result + } + + /** + * 최근 시그널을 주고받은 사용자 ID를 반환합니다. + * 7일 이내 시그널 관계가 있는 사용자를 제외합니다. + * + * MemberService의 로직과 동일하게 동작합니다: + * - 가장 최근 추천 시간(10시 또는 22시) 기준으로 제외 + * - 7일 내 시그널 주고받은 사용자 제외 + * + * @param user 기준 사용자 + * @return 시그널 관계 사용자 ID Set + */ + fun getRecentSignalMemberIds(user: Member): Set { + val userId = user.getIdOrThrow() + val sevenDaysAgo = LocalDateTime.now().minusDays(7) + val targetTime = getLastRecommendationTime(LocalDateTime.now()) + + // 내가 시그널 보낸 사용자들 (최근 추천 시간 기준) + val sentSignalIds = signalJpaRepository.findExcludedFromMemberIdsAtMidnight( + user, sevenDaysAgo, targetTime + ) + + // 내가 시그널 받은 사용자들 (최근 추천 시간 기준) + val receivedSignalIds = signalJpaRepository.findExcludedToMemberIdsAtMidnight( + user, sevenDaysAgo, targetTime + ) + + val result = (sentSignalIds + receivedSignalIds).toSet() + + logger.debug { + "시그널 관계 조회 - userId: $userId, " + + "targetTime: $targetTime, " + + "sent: ${sentSignalIds.size}명, received: ${receivedSignalIds.size}명, " + + "total: ${result.size}명" + } + + return result + } + + /** + * 채팅방에서 만난 적이 있는 사용자 ID를 반환합니다. + * 현재 활성 채팅방뿐만 아니라 나간 채팅방의 사용자도 모두 제외합니다. + * + * @param user 기준 사용자 + * @return 채팅방 관계 사용자 ID Set + */ + fun getChatRoomMemberIds(user: Member): Set { + val userId = user.getIdOrThrow() + + // 사용자가 속한 모든 채팅방의 ChatRoomMember 조회 (나간 채팅방 포함) + val myChatRoomMembers = chatRoomMemberJpaRepository.findAllByMember(user) + + // 각 채팅방의 상대방 ID 추출 + val partnerIds = myChatRoomMembers.mapNotNull { myChatRoomMember -> + val chatRoomId = myChatRoomMember.chatRoom.getIdOrThrow() + + // 같은 채팅방의 다른 멤버 찾기 + val otherMember = chatRoomMemberJpaRepository.findByChatRoomIdAndMemberNot( + chatRoomId, user + ) + + otherMember?.member?.id + }.toSet() + + logger.debug { + "채팅방 관계 조회 - userId: $userId, " + + "myChatRooms: ${myChatRoomMembers.size}개, " + + "partners: ${partnerIds.size}명, " + + "partnerIds: $partnerIds" + } + + return partnerIds + } + + /** + * 가장 최근의 추천 시간을 구합니다. (10시 또는 22시) + * MemberService의 로직과 동일합니다. + * + * @param now 현재 시간 + * @return 가장 최근 추천 시간 (10시 또는 22시) + */ + private fun getLastRecommendationTime(now: LocalDateTime): LocalDateTime { + val currentHour = now.hour + val today = now.toLocalDate() + + return when { + currentHour >= 22 -> today.atTime(22, 0) // 오늘 22시 + currentHour >= 10 -> today.atTime(10, 0) // 오늘 10시 + else -> today.minusDays(1).atTime(22, 0) // 어제 22시 + } + } + + /** + * 제외 로직 통계 정보 조회 (디버깅/모니터링용) + * + * @param user 기준 사용자 + * @param type 추천 타입 + * @return 제외 통계 정보 + */ + fun getExclusionStatistics(user: Member, type: RecommendationType): Map { + val userId = user.getIdOrThrow() + + val historyExcludeIds = if (config.allowDuplicate) { + historyService.getExcludedUserIds(user) + } else { + historyService.getExcludedUserIdsByType(user, type) + } + + val blockedIds = getBlockedMemberIds(user) + val signalIds = getRecentSignalMemberIds(user) + val allExcludeIds = getAllExcludedIds(user, type) + + return mapOf( + "userId" to userId, + "type" to type.name, + "excludedCounts" to mapOf( + "self" to 1, + "history" to historyExcludeIds.size, + "blocked" to blockedIds.size, + "signal" to signalIds.size, + "total" to allExcludeIds.size + ), + "targetTime" to getLastRecommendationTime(LocalDateTime.now()).toString(), + "config" to mapOf( + "repeatAvoidDays" to config.repeatAvoidDays, + "allowDuplicate" to config.allowDuplicate + ) + ) + } +} diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationHistoryService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationHistoryService.kt new file mode 100644 index 00000000..9e891ed2 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/RecommendationHistoryService.kt @@ -0,0 +1,328 @@ +package codel.recommendation.business + +import codel.config.Loggable +import codel.member.domain.Member +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationHistory +import codel.recommendation.domain.RecommendationType +import codel.recommendation.infrastructure.RecommendationHistoryJpaRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import io.github.oshai.kotlinlogging.KotlinLogging +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 추천 이력 저장 및 조회를 담당하는 서비스 + * + * 주요 기능: + * - 추천 결과 이력 저장 (배치 처리 최적화) + * - 중복 방지를 위한 제외 대상 조회 + * - 추천 타입별 이력 관리 (오늘의 코드매칭 vs 코드타임) + * - 성능 최적화를 위한 배치 저장 + */ +@Service +@Transactional +class RecommendationHistoryService( + private val recommendationHistoryJpaRepository: RecommendationHistoryJpaRepository, + private val config: RecommendationConfig, + private val timeZoneService: TimeZoneService +) : Loggable{ + + /** + * 추천 이력을 배치로 저장합니다. + * 트랜잭션 안전성을 보장하며, 성능을 위해 saveAll()을 사용합니다. + * + * @param user 추천을 받은 사용자 + * @param recommendedUsers 추천된 사용자들 목록 + * @param type 추천 타입 (DAILY_CODE_MATCHING or CODE_TIME) + * @param timeSlot 코드타임의 경우 시간대 정보 (예: "10:00", "22:00") + * @param dateTime 추천 시각 (기본값: 현재 시각) + */ + @Transactional(readOnly = false) + fun saveRecommendationHistory( + user: Member, + recommendedUsers: List, + type: RecommendationType, + timeSlot: String? = null, + dateTime: LocalDateTime = LocalDateTime.now() + ) { + if (recommendedUsers.isEmpty()) { + log.warn { "추천 사용자 목록이 비어있습니다 - userId: ${user.getIdOrThrow()}, type: $type" } + return + } + + val histories = recommendedUsers.map { recommendedUser -> + RecommendationHistory( + user = user, + recommendedUser = recommendedUser, + recommendedAt = dateTime, + recommendationType = type, + recommendationTimeSlot = timeSlot + ) + } + + try { + recommendationHistoryJpaRepository.saveAll(histories) + log.info { + "추천 이력 저장 완료 - userId: ${user.getIdOrThrow()}, " + + "type: $type, timeSlot: $timeSlot, count: ${histories.size}개" + } + } catch (e: Exception) { + log.error(e) { + "추천 이력 저장 실패 - userId: ${user.getIdOrThrow()}, " + + "type: $type, count: ${histories.size}개" + } + throw e + } + } + + /** + * 중복 방지를 위해 제외해야 할 사용자 ID 목록을 조회합니다. + * repeatAvoidDays 설정에 따라 최근 N일 내 추천받은 사용자들을 제외합니다. + * + * @param user 기준이 되는 사용자 + * @return 제외해야 할 사용자 ID Set + */ + fun getExcludedUserIds(user: Member): Set { + val fromDateTime = LocalDateTime.now().minusDays(config.repeatAvoidDays.toLong()) + + val excludedIds = recommendationHistoryJpaRepository.findRecommendedUserIdsInPeriod( + user = user, + fromDateTime = fromDateTime + ).toSet() + + log.debug { + "중복 방지 대상 조회 - userId: ${user.getIdOrThrow()}, " + + "period: ${config.repeatAvoidDays}일, excludedCount: ${excludedIds.size}개" + } + + return excludedIds + } + + /** + * 특정 추천 타입에 대한 제외 대상을 조회합니다. + * allowDuplicate 설정에 따라 타입별 중복 허용 여부를 결정할 때 사용합니다. + * + * @param user 기준이 되는 사용자 + * @param type 추천 타입 + * @return 해당 타입에서 제외해야 할 사용자 ID Set + */ + fun getExcludedUserIdsByType(user: Member, type: RecommendationType): Set { + val fromDateTime = LocalDateTime.now().minusDays(config.repeatAvoidDays.toLong()) + + val excludedIds = recommendationHistoryJpaRepository.findRecommendedUserIdsByTypeInPeriod( + user = user, + type = type, + fromDateTime = fromDateTime + ).toSet() + + log.debug { + "타입별 중복 방지 대상 조회 - userId: ${user.getIdOrThrow()}, " + + "type: $type, excludedCount: ${excludedIds.size}개" + } + + return excludedIds + } + + /** + * 오늘의 코드매칭에서 추천받은 사용자 ID 목록을 조회합니다. + * 타임존 기준으로 "오늘"을 판단합니다. + * + * 예: 타임존이 KST이고 2025-10-29 02:00에 호출 + * → 2025-10-29 00:00 KST ~ 2025-10-30 00:00 KST 사이의 추천 조회 + * → UTC로는 2025-10-28 15:00 UTC ~ 2025-10-29 15:00 UTC + * + * @param user 추천을 받은 사용자 + * @param timeZoneId 타임존 ID (null이면 기본값 KST 사용) + * @return 오늘 추천받은 사용자 ID 목록 (생성 순서대로 정렬) + */ + fun getTodayDailyCodeMatchingIds(user: Member, timeZoneId: String? = null): List { + // 타임존 기준 오늘 자정 ~ 내일 자정 (UTC로 변환) + val todayStartUTC = timeZoneService.getTodayStartInUTC(timeZoneId) + val tomorrowStartUTC = timeZoneService.getTomorrowStartInUTC(timeZoneId) + + val recommendedIds = recommendationHistoryJpaRepository.findDailyCodeMatchingIdsByTimeRange( + user = user, + startDateTime = todayStartUTC, + endDateTime = tomorrowStartUTC + ) + + log.debug { + "오늘의 코드매칭 이력 조회 - userId: ${user.getIdOrThrow()}, " + + "range(UTC): $todayStartUTC ~ $tomorrowStartUTC, count: ${recommendedIds.size}개" + } + + return recommendedIds + } + + /** + * 특정 시간대 코드타임에서 추천받은 사용자 ID 목록을 조회합니다. + * 시간대별 추천 결과를 재사용할 때 사용합니다. + * + * @param user 추천을 받은 사용자 + * @param timeSlot 시간대 (예: "10:00", "22:00") + * @return 해당 시간대에 추천받은 사용자 ID 목록 (생성 순서대로 정렬) + */ + fun getTodayCodeTimeIds(user: Member, timeSlot: String): List { + val today = LocalDate.now() + + val recommendedIds = recommendationHistoryJpaRepository.findTodayCodeTimeIdsBySlot( + user = user, + timeSlot = timeSlot, + today = today + ) + + log.debug { + "코드타임 이력 조회 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, today: $today, count: ${recommendedIds.size}개" + } + + return recommendedIds + } + + /** + * 시간 범위 내 코드타임 추천 사용자 ID를 조회합니다. + * 타임존을 고려하여 올바른 시간 범위를 계산합니다. + * + * @param user 추천을 받은 사용자 + * @param timeSlot 시간대 (예: "10:00", "22:00") + * @param startDateTime 시작 시간 (포함, UTC 기준) + * @param endDateTime 종료 시간 (미포함, UTC 기준) + * @return 해당 시간 범위 내 추천받은 사용자 ID 목록 + */ + fun getCodeTimeIdsByTimeRange( + user: Member, + timeSlot: String, + startDateTime: LocalDateTime, + endDateTime: LocalDateTime + ): List { + val recommendedIds = recommendationHistoryJpaRepository.findCodeTimeIdsByTimeRange( + user = user, + timeSlot = timeSlot, + startDateTime = startDateTime, + endDateTime = endDateTime + ) + + log.debug { + "코드타임 범위 조회 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, range(UTC): $startDateTime ~ $endDateTime, " + + "count: ${recommendedIds.size}개" + } + + return recommendedIds + } + + /** + * 추천 이력이 존재하는지 확인합니다. + * 추천 생성 여부를 판단할 때 사용합니다. + * + * @param user 추천을 받은 사용자 + * @param type 추천 타입 + * @param date 확인할 날짜 (기본값: 오늘) + * @return 해당 날짜에 해당 타입의 추천 이력이 있는지 여부 + */ + fun hasRecommendationHistory( + user: Member, + type: RecommendationType, + date: LocalDate = LocalDate.now() + ): Boolean { + val hasHistory = recommendationHistoryJpaRepository.existsByUserAndTypeAndDate( + user = user, + type = type, + date = date + ) + + log.debug { + "추천 이력 존재 확인 - userId: ${user.getIdOrThrow()}, " + + "type: $type, date: $date, exists: $hasHistory" + } + + return hasHistory + } + + /** + * 특정 시간대 코드타임 이력이 존재하는지 확인합니다. + * + * @param user 추천을 받은 사용자 + * @param timeSlot 시간대 (예: "10:00", "22:00") + * @param date 확인할 날짜 (기본값: 오늘) + * @return 해당 날짜의 해당 시간대에 코드타임 추천 이력이 있는지 여부 + */ + fun hasCodeTimeHistory( + user: Member, + timeSlot: String, + date: LocalDate = LocalDate.now() + ): Boolean { + val hasHistory = recommendationHistoryJpaRepository.existsByUserAndTimeSlotAndDate( + user = user, + timeSlot = timeSlot, + date = date + ) + + log.debug { + "코드타임 이력 존재 확인 - userId: ${user.getIdOrThrow()}, " + + "timeSlot: $timeSlot, date: $date, exists: $hasHistory" + } + + return hasHistory + } + + /** + * 사용자별 총 추천받은 고유 사용자 수를 조회합니다. + * 통계 및 분석 목적으로 사용합니다. + * + * @param user 기준이 되는 사용자 + * @return 지금까지 추천받은 고유 사용자 수 + */ + fun getTotalUniqueRecommendedCount(user: Member): Long { + val count = recommendationHistoryJpaRepository.countUniqueRecommendedUsers(user) + + log.debug { + "총 추천받은 고유 사용자 수 조회 - userId: ${user.getIdOrThrow()}, count: $count" + } + + return count + } + + /** + * 특정 사용자의 모든 추천 이력을 삭제합니다. + * 회원 탈퇴 시 개인정보 보호를 위해 사용합니다. + * + * @param user 삭제할 사용자 + * @return 삭제된 이력 수 + */ + @Transactional(readOnly = false) + fun deleteAllHistoryForUser(user: Member): Long { + val deletedCount = recommendationHistoryJpaRepository.deleteByUser(user) + + recommendationHistoryJpaRepository.deleteByRecommendedUser(user) + + log.info { + "사용자 추천 이력 삭제 완료 - userId: ${user.getIdOrThrow()}, " + + "deletedCount: ${deletedCount}개" + } + + return deletedCount + } + + /** + * 오래된 추천 이력을 정리합니다. + * 배치 작업에서 사용하여 데이터베이스 크기를 관리합니다. + * + * @param cutoffDays 보관 기간 (일) - 이보다 오래된 이력 삭제 + * @return 삭제된 이력 수 + */ + @Transactional(readOnly = false) + fun cleanupOldHistories(cutoffDays: Int = 90): Long { + val cutoffDateTime = LocalDateTime.now().minusDays(cutoffDays.toLong()) + + val deletedCount = recommendationHistoryJpaRepository.deleteByRecommendedAtBefore(cutoffDateTime) + + log.info { + "오래된 추천 이력 정리 완료 - cutoffDays: ${cutoffDays}일, " + + "cutoffDateTime: $cutoffDateTime, deletedCount: ${deletedCount}개" + } + + return deletedCount + } +} diff --git a/src/main/kotlin/codel/recommendation/business/RecommendationService.kt b/src/main/kotlin/codel/recommendation/business/RecommendationService.kt new file mode 100644 index 00000000..8f3ff31c --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/RecommendationService.kt @@ -0,0 +1,170 @@ +package codel.recommendation.business + +import codel.config.Loggable +import codel.member.domain.Member +import codel.recommendation.domain.CodeTimeRecommendationResult +import codel.recommendation.domain.RecommendationConfig +import codel.recommendation.domain.RecommendationType +import codel.member.business.MemberService +import codel.member.presentation.response.FullProfileResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDate +import java.time.LocalTime + +/** + * 통합 추천 서비스 + * + * 주요 기능: + * - 오늘의 코드매칭과 코드타임을 통합 관리 + * - 사용자 상황에 맞는 최적의 추천 제공 + * - 기존 MemberService와의 연동점 역할 + * - 추천 시스템 전체 상태 모니터링 + * + * 동시성 제어: + * - synchronized + TransactionTemplate 조합으로 중복 추천 완벽 차단 + * - synchronized 블록 내에서 트랜잭션 커밋까지 완료 보장 + */ +@Service +class RecommendationService( + private val dailyCodeMatchingService: DailyCodeMatchingService, + private val codeTimeService: CodeTimeService, + private val config: RecommendationConfig, + private val transactionTemplate: TransactionTemplate +) : Loggable { + + /** + * 사용자별 락 객체를 관리하는 맵 + * + * 동시성 제어: + * - 같은 사용자의 getDailyCodeMatching + getCodeTime 동시 요청 시 중복 추천 방지 + * - 사용자별로 독립적인 락 사용 → 다른 사용자에게 영향 없음 + * + * 메모리: + * - 락 객체 1개 = 16 bytes + * - 100만 사용자 = 24MB (무시 가능) + * - GC가 필요 시 자동 처리 + */ + private val userLocks = java.util.concurrent.ConcurrentHashMap() + + /** + * 오늘의 코드매칭만 조회합니다. + * + * 동시성 제어: + * - synchronized 블록 내에서 트랜잭션 실행 및 커밋 + * - 락 해제 시점에 이미 DB 저장 완료 → 중복 추천 완벽 차단 + * + * @param user 추천을 받을 사용자 + * @return 오늘의 코드매칭 결과 + */ + fun getDailyCodeMatching(user: Member): List { + val userId = user.getIdOrThrow() + log.info { "오늘의 코드매칭 요청 - userId: $userId" } + + val lock = userLocks.computeIfAbsent(userId) { Any() } + + return synchronized(lock) { + log.info { "락 획득 성공, 트랜잭션 시작 - userId: $userId" } + + // synchronized 블록 안에서 트랜잭션 실행 및 커밋 + val result = transactionTemplate.execute { + dailyCodeMatchingService.getDailyCodeMatching(user) + } ?: emptyList() + + log.info { "트랜잭션 커밋 완료, 추천 ${result.size}명 - userId: $userId, members: ${result.map { it.getIdOrThrow() }}" } + result + }.also { + log.info { "락 해제 - userId: $userId" } + } + } + + /** + * 코드타임만 조회합니다. + * + * 동시성 제어: + * - synchronized 블록 내에서 트랜잭션 실행 및 커밋 + * - getDailyCodeMatching과 동일한 락 사용 → 두 API 간 중복 방지 + * + * @param user 추천을 받을 사용자 + * @return 코드타임 결과 (DTO로 변환 완료) + */ + fun getCodeTime(user: Member, page: Int, size: Int): Page { + val userId = user.getIdOrThrow() + log.info { "코드타임 요청 - userId: $userId" } + + val lock = userLocks.computeIfAbsent(userId) { Any() } + + return synchronized(lock) { + log.info { "락 획득 성공, 트랜잭션 시작 - userId: $userId" } + + // synchronized 블록 안에서 트랜잭션 실행 및 DTO 변환까지 완료 + val result = transactionTemplate.execute { + val memberPage = codeTimeService.getCodeTimeRecommendation(user, page, size) + + // 트랜잭션 내에서 DTO 변환 → Lazy Loading 문제 해결 + memberPage.map { memberEntity -> + FullProfileResponse.createOpen(memberEntity) + } + } ?: PageImpl(emptyList()) + + log.info { "트랜잭션 커밋 완료, 추천 ${result.content.size}명 - userId: $userId" } + result + }.also { + log.info { "락 해제 - userId: $userId" } + } + } + + /** + * 추천 시스템 전체 설정을 조회합니다. + * + * @return 현재 추천 시스템 설정 + */ + fun getRecommendationSettings(): Map { + return mapOf( + "dailyCodeCount" to config.dailyCodeCount, + "codeTimeCount" to config.codeTimeCount, + "codeTimeSlots" to config.codeTimeSlots, + "dailyRefreshTime" to config.dailyRefreshTime, + "repeatAvoidDays" to config.repeatAvoidDays, + "allowDuplicate" to config.allowDuplicate, + "currentTime" to LocalTime.now().toString(), + "currentDate" to LocalDate.now().toString() + ) + } +} + +/** + * 통합 추천 결과 데이터 클래스 + */ +data class RecommendationResult( + val primaryRecommendation: PrimaryRecommendation, + val codeTimeResult: CodeTimeRecommendationResult?, + val dailyCodeMatching: List, + val recommendationMessage: String +) + +/** + * 주 추천 타입 enum + */ +enum class PrimaryRecommendation { + DAILY_CODE_MATCHING, // 오늘의 코드매칭 우선 + CODE_TIME // 코드타임 우선 +} + +/** + * 추천 현황 종합 데이터 클래스 + */ +data class RecommendationOverview( + val userId: Long, + val hasDailyCodeMatching: Boolean, + val currentTimeSlot: String?, + val nextTimeSlot: String?, + val isCodeTimeActive: Boolean, + val dailyCodeMatchingStats: Map, + val codeTimeStats: Map, + val allCodeTimeResults: Map, + val bucketStatistics: Map, + val totalUniqueRecommendationCount: Long +) diff --git a/src/main/kotlin/codel/recommendation/business/TimeSlotCalculator.kt b/src/main/kotlin/codel/recommendation/business/TimeSlotCalculator.kt new file mode 100644 index 00000000..d0c5ecb1 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/TimeSlotCalculator.kt @@ -0,0 +1,61 @@ +package codel.recommendation.business + +import java.time.* + +class TimeSlotCalculator(zone: String) { + + private val zoneId: ZoneId = when(zone.lowercase()){ + "ko", "kst" -> ZoneId.of("Asia/Seoul") + "utc" -> ZoneOffset.UTC + else -> throw IllegalArgumentException("지원하지 않는 타임 존 : $zone") + } + + fun getCurrentTimeSlot(): String { + val now = LocalTime.now(zoneId) + val hour = now.hour + + return if (hour in 10..21) { + "10:00" + } else { + "22:00" + } + } + + /** + * 현재 시각을 ZonedDateTime으로 반환 + */ + fun now(): ZonedDateTime = ZonedDateTime.now(zoneId) + + /** + * 현재 날짜를 LocalDate로 반환 + */ + fun today(): LocalDate = LocalDate.now(zoneId) + + /** + * 주어진 시간대("10:00" 또는 "22:00")의 유효 기간을 반환합니다. + */ + fun getValidRangeFor(timeSlot: String): Pair { + val now = ZonedDateTime.now(zoneId) + val today = now.toLocalDate() + + return when (timeSlot) { + "10:00" -> { + val start = LocalDateTime.of(today, LocalTime.of(10, 0)) + val end = LocalDateTime.of(today, LocalTime.of(22, 0)) + start to end + } + + "22:00" -> { + val start = if (now.hour >= 22) + LocalDateTime.of(today, LocalTime.of(22, 0)) + else + LocalDateTime.of(today.minusDays(1), LocalTime.of(22, 0)) + + val end = start.plusHours(12) + start to end + } + + else -> throw IllegalArgumentException("잘못된 시간대: $timeSlot") + } + } +} diff --git a/src/main/kotlin/codel/recommendation/business/TimeZoneService.kt b/src/main/kotlin/codel/recommendation/business/TimeZoneService.kt new file mode 100644 index 00000000..936d4c78 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/business/TimeZoneService.kt @@ -0,0 +1,219 @@ +package codel.recommendation.business + +import codel.config.Loggable +import org.springframework.stereotype.Service +import java.time.* + +/** + * 타임존 기반 시간 계산 서비스 + * + * 주요 기능: + * - 타임존 기반 날짜/시간 계산 + * - UTC로 저장된 시간을 타임존으로 변환 + * + * 향후 확장: + * - HTTP 헤더 'X-Timezone'에서 타임존 정보를 받아 처리 + */ +@Service +class TimeZoneService : Loggable { + + companion object { + // 한국 타임존 + private val KST = ZoneId.of("Asia/Seoul") + + // 기본 타임존 (한국) + private val DEFAULT_ZONE = KST + } + + /** + * 타임존을 반환합니다. + * + * 현재는 한국(KST) 고정이지만, 향후 HTTP 헤더에서 타임존을 받아 처리할 예정입니다. + * + * @param timeZoneId 타임존 ID (예: "Asia/Seoul", "America/New_York") + * @return 타임존 (기본값: Asia/Seoul) + */ + fun getTimeZone(timeZoneId: String? = null): ZoneId { + return timeZoneId?.let { + try { + ZoneId.of(it) + } catch (e: Exception) { + log.warn { "Invalid timezone: $it, using default: $DEFAULT_ZONE" } + DEFAULT_ZONE + } + } ?: DEFAULT_ZONE + } + + /** + * 타임존 기준으로 현재 날짜를 반환합니다. + * + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 타임존 기준 오늘 날짜 + */ + fun getToday(timeZoneId: String? = null): LocalDate { + val zone = getTimeZone(timeZoneId) + return LocalDate.now(zone) + } + + /** + * 타임존 기준으로 현재 시각을 반환합니다. + * + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 타임존 기준 현재 시각 + */ + fun getNow(timeZoneId: String? = null): LocalDateTime { + val zone = getTimeZone(timeZoneId) + return LocalDateTime.now(zone) + } + + /** + * 타임존 기준으로 오늘 자정(00:00)의 UTC 시각을 반환합니다. + * + * 예: 타임존이 KST이고 2025-10-29일 경우 + * → 2025-10-29 00:00 KST = 2025-10-28 15:00 UTC + * + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 타임존 기준 오늘 자정의 UTC LocalDateTime + */ + fun getTodayStartInUTC(timeZoneId: String? = null): LocalDateTime { + val zone = getTimeZone(timeZoneId) + val today = LocalDate.now(zone) + + // 타임존의 오늘 자정 + val midnight = today.atStartOfDay(zone) + + // UTC로 변환 + return midnight + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } + + /** + * 타임존 기준으로 내일 자정(00:00)의 UTC 시각을 반환합니다. + * + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 타임존 기준 내일 자정의 UTC LocalDateTime + */ + fun getTomorrowStartInUTC(timeZoneId: String? = null): LocalDateTime { + val zone = getTimeZone(timeZoneId) + val tomorrow = LocalDate.now(zone).plusDays(1) + + // 타임존의 내일 자정 + val midnight = tomorrow.atStartOfDay(zone) + + // UTC로 변환 + return midnight + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } + + /** + * 타임존 기준으로 특정 시간대(예: "10:00", "22:00")의 UTC 시작/종료 시각을 반환합니다. + * + * 로직: + * - 10:00 슬롯: 오늘 10:00 ~ 오늘 22:00 + * - 22:00 슬롯: 오늘 22:00 ~ 내일 10:00 + * + * @param timeSlot 시간대 ("10:00" 또는 "22:00") + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return Pair<시작시각 UTC, 종료시각 UTC> + */ + fun getTimeSlotRangeInUTC(timeSlot: String, timeZoneId: String? = null): Pair { + val zone = getTimeZone(timeZoneId) + val now = LocalDateTime.now(zone) + val today = now.toLocalDate() + + return when (timeSlot) { + "10:00" -> { + // 오늘 10:00 ~ 오늘 22:00 + val start = today.atTime(10, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + val end = today.atTime(22, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + Pair(start, end) + } + "22:00" -> { + val currentHour = now.hour + + if (currentHour >= 22) { + // 현재 22시 이후 → 오늘 22:00 ~ 내일 10:00 + val start = today.atTime(22, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + val end = today.plusDays(1).atTime(10, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + Pair(start, end) + } else { + // 현재 22시 이전 (0~21시) → 어제 22:00 ~ 오늘 10:00 + val start = today.minusDays(1).atTime(22, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + val end = today.atTime(10, 0).atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + Pair(start, end) + } + } + else -> throw IllegalArgumentException("Invalid timeSlot: $timeSlot") + } + } + + /** + * 타임존 기준으로 현재 활성 시간대를 반환합니다. + * + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 현재 활성 시간대 ("10:00" 또는 "22:00") + */ + fun getCurrentTimeSlot(timeZoneId: String? = null): String { + val zone = getTimeZone(timeZoneId) + val now = LocalDateTime.now(zone) + val currentHour = now.hour + + return if (currentHour in 10..21) { + "10:00" // 10시 ~ 21시 59분 → 10시 추천 + } else { + "22:00" // 22시 ~ 다음날 9시 59분 → 22시 추천 + } + } + + /** + * UTC로 저장된 시각을 지정된 타임존으로 변환합니다. + * + * @param utcDateTime UTC 시각 + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return 타임존의 시각 + */ + fun convertUTCToZone(utcDateTime: LocalDateTime, timeZoneId: String? = null): LocalDateTime { + val zone = getTimeZone(timeZoneId) + + return utcDateTime + .atZone(ZoneOffset.UTC) + .withZoneSameInstant(zone) + .toLocalDateTime() + } + + /** + * 타임존의 시각을 UTC로 변환합니다. + * + * @param dateTime 타임존의 시각 + * @param timeZoneId 타임존 ID (null이면 기본값 사용) + * @return UTC 시각 + */ + fun convertZoneToUTC(dateTime: LocalDateTime, timeZoneId: String? = null): LocalDateTime { + val zone = getTimeZone(timeZoneId) + + return dateTime + .atZone(zone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } +} diff --git a/src/main/kotlin/codel/recommendation/config/RecommendationBeanConfiguration.kt b/src/main/kotlin/codel/recommendation/config/RecommendationBeanConfiguration.kt new file mode 100644 index 00000000..db265777 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/config/RecommendationBeanConfiguration.kt @@ -0,0 +1,20 @@ +package codel.recommendation.config + +import codel.recommendation.business.RecommendationConfigService +import codel.recommendation.domain.RecommendationConfig +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 추천 시스템 Bean 설정 + * + * 순환 참조 방지를 위한 Bean 생성 + */ +@Configuration +class RecommendationBeanConfiguration { + + @Bean + fun recommendationConfig(configService: RecommendationConfigService): RecommendationConfig { + return RecommendationConfig { configService } + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/CodeTimeRecommendationResult.kt b/src/main/kotlin/codel/recommendation/domain/CodeTimeRecommendationResult.kt new file mode 100644 index 00000000..87e74dd3 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/CodeTimeRecommendationResult.kt @@ -0,0 +1,80 @@ +package codel.recommendation.domain + +import codel.member.domain.Member + +/** + * 코드타임 추천 결과 DTO + * + * 시간대별 추천 결과와 상태 정보를 담는 데이터 클래스 + */ +data class CodeTimeRecommendationResult( + /** + * 현재 시간대 (예: "10:00", "22:00") + * null이면 코드타임 시간대가 아님 + */ + val timeSlot: String?, + + /** + * 추천된 사용자 목록 + * 빈 리스트면 추천 대상이 없거나 비활성 시간대 + */ + val members: List, + + /** + * 현재 코드타임 활성 시간대 여부 + * true: 현재 시간이 코드타임 시간대 + * false: 비활성 시간대 + */ + val isActiveTime: Boolean, + + /** + * 다음 코드타임 시간대 정보 + * 사용자에게 다음 시간대를 알려주기 위한 용도 + */ + val nextTimeSlot: String? +) { + + /** + * 추천 결과 개수 + */ + val recommendationCount: Int + get() = members.size + + /** + * 추천 결과가 있는지 확인 + */ + val hasRecommendations: Boolean + get() = members.isNotEmpty() + + /** + * 현재 활성 시간대이면서 추천 결과가 있는지 확인 + */ + val isValidRecommendation: Boolean + get() = isActiveTime && hasRecommendations + + companion object { + /** + * 비활성 시간대용 빈 결과 생성 + */ + fun createInactiveResult(nextTimeSlot: String?): CodeTimeRecommendationResult { + return CodeTimeRecommendationResult( + timeSlot = null, + members = emptyList(), + isActiveTime = false, + nextTimeSlot = nextTimeSlot + ) + } + + /** + * 활성 시간대이지만 추천 결과가 없는 경우 + */ + fun createEmptyActiveResult(timeSlot: String, nextTimeSlot: String?): CodeTimeRecommendationResult { + return CodeTimeRecommendationResult( + timeSlot = timeSlot, + members = emptyList(), + isActiveTime = true, + nextTimeSlot = nextTimeSlot + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt new file mode 100644 index 00000000..136dfc2c --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationConfig.kt @@ -0,0 +1,123 @@ +package codel.recommendation.domain + +/** + * 추천 시스템 설정 Wrapper 클래스 + * + * 기존 코드 호환성을 위해 RecommendationConfigService를 래핑 + * 실제 데이터는 DB(RecommendationConfigEntity)에서 조회 + * + * Bean으로 생성됨 (RecommendationBeanConfiguration 참조) + */ +class RecommendationConfig( + private val configServiceProvider: () -> codel.recommendation.business.RecommendationConfigService +) { + + private val configService by lazy { configServiceProvider() } + + /** + * 오늘의 코드매칭 추천 인원 수 + */ + val dailyCodeCount: Int + get() = configService.getDailyCodeCount() + + /** + * 코드타임 추천 인원 수 + */ + val codeTimeCount: Int + get() = configService.getCodeTimeCount() + + /** + * 코드타임 시간대 목록 + */ + val codeTimeSlots: List + get() = configService.getCodeTimeSlots() + + /** + * 오늘의 코드매칭 갱신 시점 + */ + val dailyRefreshTime: String + get() = configService.getDailyRefreshTime() + + /** + * 동일 인연 재노출 금지 기간 (일 단위) + */ + val repeatAvoidDays: Int + get() = configService.getRepeatAvoidDays() + + /** + * 오늘의 코드매칭과 코드타임 간 중복 허용 여부 + */ + val allowDuplicate: Boolean + get() = configService.getAllowDuplicate() + + companion object { + + /** + * 메인 지역별 인접 지역 매핑 (우선순위 순서) + * 버킷 정책 B3에서 사용되는 인접 지역 관계 정의 + */ + val ADJACENT_MAIN_REGION_MAP = mapOf( + // 수도권 + "서울" to listOf("경기", "인천"), + "경기" to listOf("서울", "인천", "강원", "충남"), + "인천" to listOf("서울", "경기"), + + // 영남권 + "부산" to listOf("울산", "경남"), + "울산" to listOf("부산", "경남", "경북"), + "대구" to listOf("경북", "경남"), + + // 호남권 + "광주" to listOf("전남", "전북"), + + // 충청권 + "대전" to listOf("세종", "충남", "충북"), + "세종" to listOf("대전", "충남", "충북"), + + // 강원권 + "강원" to listOf("경기", "충북", "경북"), + + // 충청권 확장 + "충북" to listOf("세종", "대전", "충남", "강원", "경북"), + "충남" to listOf("세종", "대전", "경기", "전북"), + + // 전라권 + "전북" to listOf("전남", "충남", "경남"), + "전남" to listOf("광주", "전북", "경남", "제주"), + + // 경상권 + "경북" to listOf("대구", "경남", "강원"), + "경남" to listOf("부산", "울산", "전남", "경북"), + + // 제주권 + "제주" to emptyList() + ) + + /** + * 특정 메인 지역의 인접 지역 목록을 우선순위 순으로 반환 + */ + fun getAdjacentRegions(mainRegion: String): List { + return ADJACENT_MAIN_REGION_MAP[mainRegion] ?: emptyList() + } + + /** + * 코드타임 시간대 검증 (30분 허용 오차) + */ + fun isWithinTimeSlot(currentHour: Int, currentMinute: Int, timeSlot: String): Boolean { + val (slotHour, slotMinute) = timeSlot.split(":").map { it.toInt() } + val currentTotalMinutes = currentHour * 60 + currentMinute + val slotTotalMinutes = slotHour * 60 + slotMinute + + return kotlin.math.abs(currentTotalMinutes - slotTotalMinutes) <= 30 + } + + /** + * 현재 시간에 해당하는 코드타임 시간대 반환 + */ + fun getCurrentTimeSlot(currentHour: Int, currentMinute: Int, timeSlots: List): String? { + return timeSlots.firstOrNull { timeSlot -> + isWithinTimeSlot(currentHour, currentMinute, timeSlot) + } + } + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt new file mode 100644 index 00000000..a82c4d06 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationConfigEntity.kt @@ -0,0 +1,99 @@ +package codel.recommendation.domain + +import jakarta.persistence.* +import java.time.LocalDateTime + +/** + * 추천 시스템 설정 Entity + * + * 런타임에 변경 가능한 추천 시스템 설정값을 DB에 저장 + * 단일 레코드로 관리 (ID=1 고정) + */ +@Entity +@Table(name = "recommendation_config") +class RecommendationConfigEntity( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + /** + * 오늘의 코드매칭 추천 인원 수 + */ + @Column(nullable = false) + var dailyCodeCount: Int = 3, + + /** + * 코드타임 추천 인원 수 + */ + @Column(nullable = false) + var codeTimeCount: Int = 2, + + /** + * 코드타임 시간대 목록 (쉼표로 구분, 예: "10:00,22:00") + */ + @Column(nullable = false, length = 500) + var codeTimeSlots: String = "10:00,22:00", + + /** + * 오늘의 코드매칭 갱신 시점 + */ + @Column(nullable = false, length = 5) + var dailyRefreshTime: String = "00:00", + + /** + * 동일 인연 재노출 금지 기간 (일 단위) + */ + @Column(nullable = false) + var repeatAvoidDays: Int = 3, + + /** + * 오늘의 코드매칭과 코드타임 간 중복 허용 여부 + */ + @Column(nullable = false) + var allowDuplicate: Boolean = true, + + @Column(nullable = false, updatable = false) + val createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + +) { + + @PreUpdate + fun onUpdate() { + updatedAt = LocalDateTime.now() + } + + /** + * codeTimeSlots를 List로 변환 + */ + fun getCodeTimeSlotsAsList(): List { + return codeTimeSlots.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + + /** + * List를 codeTimeSlots 문자열로 변환 + */ + fun setCodeTimeSlotsFromList(slots: List) { + codeTimeSlots = slots.joinToString(",") + } + + companion object { + /** + * 기본 설정 생성 + */ + fun createDefault(): RecommendationConfigEntity { + return RecommendationConfigEntity( + id = 1L, + dailyCodeCount = 3, + codeTimeCount = 2, + codeTimeSlots = "10:00,22:00", + dailyRefreshTime = "00:00", + repeatAvoidDays = 3, + allowDuplicate = true + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationHistory.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationHistory.kt new file mode 100644 index 00000000..5d1ccf2a --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationHistory.kt @@ -0,0 +1,60 @@ +package codel.recommendation.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import jakarta.persistence.* +import java.time.LocalDateTime + +/** + * 추천 이력 관리 엔티티 + * 중복 방지 및 추천 결과 추적을 위한 테이블 + */ +@Entity +@Table( + name = "recommendation_history", + indexes = [ + Index(name = "idx_user_recommended_at", columnList = "user_id,recommended_user_id,recommended_at"), + Index(name = "idx_user_recommended_at_only", columnList = "user_id,recommended_at"), + Index(name = "idx_recommended_at", columnList = "recommended_at") + ] +) +class RecommendationHistory( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + /** + * 추천을 받은 사용자 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + val user: Member, + + /** + * 추천된 사용자 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recommended_user_id", nullable = false) + val recommendedUser: Member, + + /** + * 추천된 시각 + */ + @Column(name = "recommended_at", nullable = false) + val recommendedAt: LocalDateTime, + + /** + * 추천 타입 (오늘의 코드매칭 or 코드타임) + */ + @Column(name = "recommendation_type", nullable = false) + @Enumerated(EnumType.STRING) + val recommendationType: RecommendationType, + + /** + * 코드타임의 경우 시간대 정보 (예: "10:00", "22:00") + * 오늘의 코드매칭의 경우 null + */ + @Column(name = "recommendation_time_slot") + val recommendationTimeSlot: String? = null + +) : BaseTimeEntity() diff --git a/src/main/kotlin/codel/recommendation/domain/RecommendationType.kt b/src/main/kotlin/codel/recommendation/domain/RecommendationType.kt new file mode 100644 index 00000000..808aad83 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/RecommendationType.kt @@ -0,0 +1,16 @@ +package codel.recommendation.domain + +/** + * 추천 타입 + */ +enum class RecommendationType { + /** + * 오늘의 코드매칭 (24시간 유지) + */ + DAILY_CODE_MATCHING, + + /** + * 코드타임 (10시, 22시 시간대별) + */ + CODE_TIME +} diff --git a/src/main/kotlin/codel/recommendation/domain/RegionMapping.kt b/src/main/kotlin/codel/recommendation/domain/RegionMapping.kt new file mode 100644 index 00000000..eff6fe8e --- /dev/null +++ b/src/main/kotlin/codel/recommendation/domain/RegionMapping.kt @@ -0,0 +1,57 @@ +package codel.recommendation.domain + +import jakarta.persistence.* + +/** + * 지역 인접 관계 매핑 엔티티 + * 버킷 정책(B3: 인접 mainRegion)에서 사용하는 지역 간 인접 관계를 관리 + */ +@Entity +@Table( + name = "region_mappings", + indexes = [ + Index(name = "idx_main_region_priority", columnList = "main_region,priority_order") + ] +) +class RegionMapping( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + /** + * 기준이 되는 메인 지역 (예: 서울, 경기, 부산 등) + */ + @Column(name = "main_region", nullable = false, length = 20) + val mainRegion: String, + + /** + * 인접한 지역 (예: 서울의 인접 지역 → 경기, 인천) + */ + @Column(name = "adjacent_region", nullable = false, length = 20) + val adjacentRegion: String, + + /** + * 우선순위 (낮을수록 높은 우선순위) + * 예: 서울 → 경기(1), 인천(2) + */ + @Column(name = "priority_order", nullable = false) + val priorityOrder: Int + +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RegionMapping) return false + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + return id?.hashCode() ?: 0 + } + + override fun toString(): String { + return "RegionMapping(id=$id, mainRegion='$mainRegion', adjacentRegion='$adjacentRegion', priorityOrder=$priorityOrder)" + } +} diff --git a/src/main/kotlin/codel/recommendation/infrastructure/RecommendationConfigRepository.kt b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationConfigRepository.kt new file mode 100644 index 00000000..d3ab4dc9 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationConfigRepository.kt @@ -0,0 +1,14 @@ +package codel.recommendation.infrastructure + +import codel.recommendation.domain.RecommendationConfigEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RecommendationConfigRepository : JpaRepository { + + /** + * 설정은 단일 레코드로 관리 (ID=1) + */ + fun findTopByOrderByIdAsc(): RecommendationConfigEntity? +} diff --git a/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt new file mode 100644 index 00000000..5dcbef84 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/infrastructure/RecommendationHistoryJpaRepository.kt @@ -0,0 +1,225 @@ +package codel.recommendation.infrastructure + +import codel.member.domain.Member +import codel.recommendation.domain.RecommendationHistory +import codel.recommendation.domain.RecommendationType +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.time.LocalDateTime +import java.time.LocalDate + +/** + * 추천 이력 관리 Repository + * 중복 방지 및 추천 결과 추적을 위한 데이터 접근 계층 + */ +interface RecommendationHistoryJpaRepository : JpaRepository { + + /** + * 특정 사용자가 최근 N일 내에 추천받은 사용자 ID 목록 조회 + * 중복 방지를 위해 사용 + */ + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendedAt >= :fromDateTime + """) + fun findRecommendedUserIdsInPeriod( + @Param("user") user: Member, + @Param("fromDateTime") fromDateTime: LocalDateTime + ): List + + /** + * 특정 추천 타입에서 최근 N일 내에 추천받은 사용자 ID 목록 조회 + * 타입별 중복 방지 정책에 사용 + */ + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = :type + AND rh.recommendedAt >= :fromDateTime + """) + fun findRecommendedUserIdsByTypeInPeriod( + @Param("user") user: Member, + @Param("type") type: RecommendationType, + @Param("fromDateTime") fromDateTime: LocalDateTime + ): List + + /** + * 오늘의 코드매칭에서 추천받은 사용자 ID (시간 범위 기준) + * 사용자 타임존 기준 24시간 유지를 위한 기존 추천 결과 조회 + * + * @param user 사용자 + * @param startDateTime 시작 시간 (포함, UTC 기준) + * @param endDateTime 종료 시간 (미포함, UTC 기준) + * @return 해당 시간 범위 내 추천된 사용자 ID 목록 + */ + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = 'DAILY_CODE_MATCHING' + AND rh.recommendedAt >= :startDateTime + AND rh.recommendedAt < :endDateTime + ORDER BY rh.createdAt ASC + """) + fun findDailyCodeMatchingIdsByTimeRange( + @Param("user") user: Member, + @Param("startDateTime") startDateTime: LocalDateTime, + @Param("endDateTime") endDateTime: LocalDateTime + ): List + + /** + * 오늘의 코드매칭에서 추천받은 사용자 ID (오늘 기준) - 하위 호환성 + * @deprecated findDailyCodeMatchingIdsByTimeRange 사용 권장 + */ + @Deprecated("Use findDailyCodeMatchingIdsByTimeRange instead") + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = 'DAILY_CODE_MATCHING' + AND DATE(rh.recommendedAt) = :today + ORDER BY rh.createdAt ASC + """) + fun findTodayDailyCodeMatchingIds( + @Param("user") user: Member, + @Param("today") today: LocalDate + ): List + + /** + * 특정 시간대 코드타임에서 추천받은 사용자 ID (오늘 기준) + * 시간대별 추천 결과 조회 + */ + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = 'CODE_TIME' + AND rh.recommendationTimeSlot = :timeSlot + AND DATE(rh.recommendedAt) = :today + ORDER BY rh.createdAt ASC + """) + fun findTodayCodeTimeIdsBySlot( + @Param("user") user: Member, + @Param("timeSlot") timeSlot: String, + @Param("today") today: LocalDate + ): List + + /** + * 시간 범위 내 특정 시간대 코드타임 추천 조회 + * 날짜가 바뀌는 경우를 처리하기 위한 범위 조회 + * + * @param user 사용자 + * @param timeSlot 시간대 ("10:00" 또는 "22:00") + * @param startDateTime 시작 시간 (포함) + * @param endDateTime 종료 시간 (미포함) + * @return 해당 시간 범위 내 추천된 사용자 ID 목록 + */ + @Query(""" + SELECT rh.recommendedUser.id + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = 'CODE_TIME' + AND rh.recommendationTimeSlot = :timeSlot + AND rh.recommendedAt >= :startDateTime + AND rh.recommendedAt < :endDateTime + ORDER BY rh.createdAt DESC + """) + fun findCodeTimeIdsByTimeRange( + @Param("user") user: Member, + @Param("timeSlot") timeSlot: String, + @Param("startDateTime") startDateTime: LocalDateTime, + @Param("endDateTime") endDateTime: LocalDateTime + ): List + + /** + * 특정 날짜의 추천 이력 존재 여부 확인 + * 추천 생성 여부 판단에 사용 + */ + @Query(""" + SELECT COUNT(rh) > 0 + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = :type + AND DATE(rh.recommendedAt) = :date + """) + fun existsByUserAndTypeAndDate( + @Param("user") user: Member, + @Param("type") type: RecommendationType, + @Param("date") date: LocalDate + ): Boolean + + /** + * 특정 시간대의 추천 이력 존재 여부 확인 (코드타임용) + */ + @Query(""" + SELECT COUNT(rh) > 0 + FROM RecommendationHistory rh + WHERE rh.user = :user + AND rh.recommendationType = 'CODE_TIME' + AND rh.recommendationTimeSlot = :timeSlot + AND DATE(rh.recommendedAt) = :date + """) + fun existsByUserAndTimeSlotAndDate( + @Param("user") user: Member, + @Param("timeSlot") timeSlot: String, + @Param("date") date: LocalDate + ): Boolean + + /** + * 오래된 추천 이력 삭제 (성능 최적화용) + * 배치 작업에서 사용하여 테이블 크기 관리 + */ + fun deleteByRecommendedAtBefore(cutoffDateTime: LocalDateTime): Long + + /** + * 사용자별 추천 이력 삭제 (회원 탈퇴 시) + */ + fun deleteByUser(user: Member): Long + + /** + * 추천받은 사용자 기준 이력 삭제 (회원 탈퇴 시) + */ + fun deleteByRecommendedUser(recommendedUser: Member): Long + + /** + * 통계를 위한 날짜별 추천 수 조회 + */ + @Query(""" + SELECT DATE(rh.recommendedAt), COUNT(rh) + FROM RecommendationHistory rh + WHERE rh.recommendedAt >= :fromDateTime + GROUP BY DATE(rh.recommendedAt) + ORDER BY DATE(rh.recommendedAt) DESC + """) + fun countRecommendationsByDate(@Param("fromDateTime") fromDateTime: LocalDateTime): List> + + /** + * 사용자별 총 추천 받은 횟수 + */ + @Query(""" + SELECT COUNT(DISTINCT rh.recommendedUser.id) + FROM RecommendationHistory rh + WHERE rh.user = :user + """) + fun countUniqueRecommendedUsers(@Param("user") user: Member): Long + + /** + * 특정 기간 내 가장 많이 추천받은 사용자들 (인기도 측정) + */ + @Query(""" + SELECT rh.recommendedUser.id, COUNT(rh) as recommendCount + FROM RecommendationHistory rh + WHERE rh.recommendedAt >= :fromDateTime + GROUP BY rh.recommendedUser.id + ORDER BY recommendCount DESC + """) + fun findMostRecommendedUsers( + @Param("fromDateTime") fromDateTime: LocalDateTime, + pageable: org.springframework.data.domain.Pageable + ): List> +} diff --git a/src/main/kotlin/codel/recommendation/infrastructure/RegionMappingJpaRepository.kt b/src/main/kotlin/codel/recommendation/infrastructure/RegionMappingJpaRepository.kt new file mode 100644 index 00000000..70246245 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/infrastructure/RegionMappingJpaRepository.kt @@ -0,0 +1,186 @@ +package codel.recommendation.infrastructure + +import codel.recommendation.domain.RegionMapping +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +/** + * 지역 매핑 관리 Repository + * 버킷 정책에서 사용할 지역 인접 관계 조회를 담당 + */ +interface RegionMappingJpaRepository : JpaRepository { + + /** + * 특정 메인 지역의 인접 지역들을 우선순위 순으로 조회 + * 버킷 정책 B3 단계에서 사용 + * + * @param mainRegion 기준 메인 지역 (예: "서울") + * @return 우선순위 순으로 정렬된 인접 지역 리스트 (예: ["경기", "인천"]) + */ + @Query(""" + SELECT rm.adjacentRegion + FROM RegionMapping rm + WHERE rm.mainRegion = :mainRegion + ORDER BY rm.priorityOrder ASC + """) + fun findAdjacentRegionsByPriority(@Param("mainRegion") mainRegion: String): List + + /** + * 모든 메인 지역과 인접 지역 매핑을 우선순위와 함께 조회 + * 전체 지역 관계 파악 및 관리용 + */ + @Query(""" + SELECT rm.mainRegion, rm.adjacentRegion, rm.priorityOrder + FROM RegionMapping rm + ORDER BY rm.mainRegion, rm.priorityOrder ASC + """) + fun findAllRegionMappingsWithPriority(): List> + + /** + * 특정 메인 지역의 모든 매핑 정보 조회 + * 지역별 관계 상세 조회 시 사용 + */ + fun findByMainRegionOrderByPriorityOrder(mainRegion: String): List + + /** + * 특정 지역이 인접 지역으로 등록된 모든 매핑 조회 + * 역방향 관계 조회 (예: "경기"가 어떤 지역들의 인접 지역인지) + */ + fun findByAdjacentRegionOrderByPriorityOrder(adjacentRegion: String): List + + /** + * 특정 지역 쌍의 매핑 존재 여부 확인 + * 중복 등록 방지 및 관계 확인 + */ + fun existsByMainRegionAndAdjacentRegion(mainRegion: String, adjacentRegion: String): Boolean + + /** + * 특정 메인 지역의 매핑 개수 조회 + * 지역별 인접 관계 수 확인 + */ + fun countByMainRegion(mainRegion: String): Long + + /** + * 모든 메인 지역 목록 조회 (중복 제거) + * 지역 선택 드롭다운 등에서 사용 + */ + @Query(""" + SELECT DISTINCT rm.mainRegion + FROM RegionMapping rm + ORDER BY rm.mainRegion ASC + """) + fun findDistinctMainRegions(): List + + /** + * 모든 인접 지역 목록 조회 (중복 제거) + * 전체 지역 목록 파악용 + */ + @Query(""" + SELECT DISTINCT rm.adjacentRegion + FROM RegionMapping rm + ORDER BY rm.adjacentRegion ASC + """) + fun findDistinctAdjacentRegions(): List + + /** + * 특정 메인 지역의 N번째까지 인접 지역 조회 + * 버킷 정책에서 제한된 개수만 필요할 때 사용 + */ + @Query(""" + SELECT rm.adjacentRegion + FROM RegionMapping rm + WHERE rm.mainRegion = :mainRegion + AND rm.priorityOrder <= :maxPriority + ORDER BY rm.priorityOrder ASC + """) + fun findAdjacentRegionsByMaxPriority( + @Param("mainRegion") mainRegion: String, + @Param("maxPriority") maxPriority: Int + ): List + + /** + * 지역별 매핑 통계 조회 + * 관리자 페이지에서 지역 관계 현황 파악용 + */ + @Query(""" + SELECT rm.mainRegion, COUNT(rm) as mappingCount + FROM RegionMapping rm + GROUP BY rm.mainRegion + ORDER BY mappingCount DESC, rm.mainRegion ASC + """) + fun getRegionMappingStatistics(): List> + + /** + * 특정 메인 지역의 매핑 삭제 + * 지역 관계 재정의 시 사용 + */ + fun deleteByMainRegion(mainRegion: String): Long + + /** + * 특정 지역 쌍의 매핑 삭제 + * 개별 관계 제거 시 사용 + */ + fun deleteByMainRegionAndAdjacentRegion(mainRegion: String, adjacentRegion: String): Long + + /** + * 버킷 정책 B3를 위한 확장된 인접 지역 조회 + * 1차, 2차 인접 지역까지 확장하여 조회 + */ + @Query(""" + SELECT DISTINCT rm2.adjacentRegion + FROM RegionMapping rm1 + JOIN RegionMapping rm2 ON rm1.adjacentRegion = rm2.mainRegion + WHERE rm1.mainRegion = :mainRegion + AND rm2.adjacentRegion != :mainRegion + ORDER BY rm2.adjacentRegion ASC + """) + fun findSecondDegreeAdjacentRegions(@Param("mainRegion") mainRegion: String): List + + /** + * 지역 간 최단 인접 거리 계산 + * 두 지역이 직접 인접한지, 1단계 건너뛰는지 확인 + */ + @Query(""" + SELECT CASE + WHEN EXISTS ( + SELECT 1 FROM RegionMapping rm + WHERE rm.mainRegion = :fromRegion + AND rm.adjacentRegion = :toRegion + ) THEN 1 + WHEN EXISTS ( + SELECT 1 FROM RegionMapping rm1 + JOIN RegionMapping rm2 ON rm1.adjacentRegion = rm2.mainRegion + WHERE rm1.mainRegion = :fromRegion + AND rm2.adjacentRegion = :toRegion + ) THEN 2 + ELSE 999 + END + """) + fun findRegionDistance( + @Param("fromRegion") fromRegion: String, + @Param("toRegion") toRegion: String + ): Int + + /** + * 특정 지역에서 접근 가능한 모든 지역 조회 (1-2차 인접 포함) + * 버킷 B3에서 확장된 후보군 생성 시 사용 + */ + @Query(""" + SELECT rm.adjacentRegion, rm.priorityOrder, 1 as degree + FROM RegionMapping rm + WHERE rm.mainRegion = :mainRegion + + UNION + + SELECT rm2.adjacentRegion, (rm1.priorityOrder * 10 + rm2.priorityOrder) as priorityOrder, 2 as degree + FROM RegionMapping rm1 + JOIN RegionMapping rm2 ON rm1.adjacentRegion = rm2.mainRegion + WHERE rm1.mainRegion = :mainRegion + AND rm2.adjacentRegion != :mainRegion + + ORDER BY degree ASC, priorityOrder ASC + """) + fun findAllAccessibleRegions(@Param("mainRegion") mainRegion: String): List> +} + diff --git a/src/main/kotlin/codel/recommendation/presentation/RecommendationConfigController.kt b/src/main/kotlin/codel/recommendation/presentation/RecommendationConfigController.kt new file mode 100644 index 00000000..2864eaca --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/RecommendationConfigController.kt @@ -0,0 +1,82 @@ +package codel.recommendation.presentation + +import codel.config.Loggable +import codel.recommendation.business.RecommendationConfigService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v1/admin/recommendation-config") +class RecommendationConfigController( + private val configService: RecommendationConfigService +) : Loggable { + + @GetMapping + fun getConfig(): ResponseEntity> { + log.info { "추천 시스템 설정 조회" } + return ResponseEntity.ok(configService.getConfigAsMap()) + } + + @PutMapping + fun updateConfig(@RequestBody request: UpdateConfigRequest): ResponseEntity> { + log.info { "추천 시스템 설정 업데이트 - request: $request" } + + return try { + val updated = configService.updateConfig( + dailyCodeCount = request.dailyCodeCount, + codeTimeCount = request.codeTimeCount, + codeTimeSlots = request.codeTimeSlots, + dailyRefreshTime = request.dailyRefreshTime, + repeatAvoidDays = request.repeatAvoidDays, + allowDuplicate = request.allowDuplicate + ) + + ResponseEntity.ok(mapOf( + "success" to true, + "message" to "설정이 업데이트되었습니다", + "config" to mapOf( + "dailyCodeCount" to updated.dailyCodeCount, + "codeTimeCount" to updated.codeTimeCount, + "codeTimeSlots" to updated.getCodeTimeSlotsAsList(), + "dailyRefreshTime" to updated.dailyRefreshTime, + "repeatAvoidDays" to updated.repeatAvoidDays, + "allowDuplicate" to updated.allowDuplicate + ) + )) + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(mapOf( + "success" to false, + "message" to (e.message ?: "잘못된 값입니다") + )) + } + } + + @PatchMapping("/daily-code-count") + fun updateDailyCodeCount(@RequestParam count: Int): ResponseEntity> { + return try { + configService.updateConfig(dailyCodeCount = count) + ResponseEntity.ok(mapOf("success" to true, "message" to "변경 완료")) + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(mapOf("success" to false, "message" to e.message!!)) + } + } + + @PatchMapping("/code-time-count") + fun updateCodeTimeCount(@RequestParam count: Int): ResponseEntity> { + return try { + configService.updateConfig(codeTimeCount = count) + ResponseEntity.ok(mapOf("success" to true, "message" to "변경 완료")) + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(mapOf("success" to false, "message" to e.message!!)) + } + } +} + +data class UpdateConfigRequest( + val dailyCodeCount: Int? = null, + val codeTimeCount: Int? = null, + val codeTimeSlots: List? = null, + val dailyRefreshTime: String? = null, + val repeatAvoidDays: Int? = null, + val allowDuplicate: Boolean? = null +) diff --git a/src/main/kotlin/codel/recommendation/presentation/RecommendationController.kt b/src/main/kotlin/codel/recommendation/presentation/RecommendationController.kt new file mode 100644 index 00000000..9a62f865 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/RecommendationController.kt @@ -0,0 +1,260 @@ +package codel.recommendation.presentation + +import codel.config.argumentresolver.LoginMember +import codel.config.Loggable +import codel.member.domain.Member +import codel.recommendation.business.RecommendationService +import codel.recommendation.domain.RecommendationType +import codel.recommendation.presentation.response.* +import codel.recommendation.presentation.swagger.RecommendationSwagger +import codel.member.presentation.response.MemberRecommendResponse +import codel.member.presentation.response.FullProfileResponse +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 추천 시스템 API Controller + * + * 새로운 지역 기반 버킷 정책 + 시간대별 추천 시스템 제공 + * 기존 랜덤 추천을 대체하는 고도화된 추천 서비스 + */ +@RestController +@RequestMapping("/v1/recommendations") +class RecommendationController( + private val recommendationService: RecommendationService +) : RecommendationSwagger, Loggable { + + @GetMapping("/daily-code-matching") + override fun getDailyCodeMatching( + @LoginMember member: Member + ): ResponseEntity { + log.info { "오늘의 코드매칭 API 호출 - userId: ${member.getIdOrThrow()}" } + + val members = recommendationService.getDailyCodeMatching(member) + + log.info { "오늘의 코드매칭 API 응답 - userId: ${member.getIdOrThrow()}, count: ${members.size}" } + + return ResponseEntity.ok(MemberRecommendResponse.from(members)) + } + + @GetMapping("/random") + override fun getCodeTime( + @LoginMember member: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "8") size: Int + ): ResponseEntity> { + log.info { "코드타임 API 호출 - userId: ${member.getIdOrThrow()}" } + + // Service에서 DTO 변환까지 완료된 결과 받기 + val responsePage = recommendationService.getCodeTime(member, page, size) + + return ResponseEntity.ok(responsePage) + } + +// @GetMapping("/random") +// override fun getRandomMembers( +// @LoginMember member: Member, +// @RequestParam(defaultValue = "0") page: Int, +// @RequestParam(defaultValue = "8") size: Int +// ): ResponseEntity> { +// log.info { +// "랜덤 추천 (파도타기) API 호출 - userId: ${member.getIdOrThrow()}, " + +// "page: $page, size: $size" +// } +// +// val memberPage = recommendationService.getRandomMembers(member, page, size) +// +// log.info { +// "랜덤 추천 (파도타기) API 응답 - userId: ${member.getIdOrThrow()}, " + +// "totalElements: ${memberPage.totalElements}, currentPage: ${memberPage.number}" +// } +// +// return ResponseEntity.ok( +// memberPage.map { memberEntity -> +// FullProfileResponse.createOpen(memberEntity) +// } +// ) +// } + +// @GetMapping("/code-time/{timeSlot}") +// fun getCodeTimeBySlot( +// @LoginMember member: Member, +// @PathVariable timeSlot: String +// ): ResponseEntity { +// log.info { "특정 시간대 코드타임 API 호출 - userId: ${member.getIdOrThrow()}, timeSlot: $timeSlot" } +// +// val result = recommendationService.getCodeTimeBySlot(member, timeSlot) +// val response = CodeTimeResponse.from(result) +// +// log.info { +// "특정 시간대 코드타임 API 응답 - userId: ${member.getIdOrThrow()}, " + +// "timeSlot: $timeSlot, count: ${result.recommendationCount}" +// } +// +// return ResponseEntity.ok(response) +// } +// +// @GetMapping("/overview") +// fun getRecommendationOverview( +// @LoginMember member: Member +// ): ResponseEntity { +// log.info { "추천 현황 종합 조회 API 호출 - userId: ${member.getIdOrThrow()}" } +// +// val overview = recommendationService.getRecommendationOverview(member) +// val response = RecommendationOverviewResponse.from(overview) +// +// log.info { +// "추천 현황 종합 조회 API 응답 - userId: ${member.getIdOrThrow()}, " + +// "hasDaily: ${overview.hasDailyCodeMatching}, " + +// "isCodeTimeActive: ${overview.isCodeTimeActive}" +// } +// +// return ResponseEntity.ok(response) +// } +// + @GetMapping("/settings") + fun getRecommendationSettings(): ResponseEntity> { + log.info { "추천 시스템 설정 조회 API 호출" } + + val settings = recommendationService.getRecommendationSettings() + + log.info { "추천 시스템 설정 조회 API 응답 - settings: $settings" } + + return ResponseEntity.ok(settings) + } +// +// @PostMapping("/refresh") +// fun forceRefreshRecommendation( +// @LoginMember member: Member, +// @RequestParam type: String, +// @RequestParam(required = false) timeSlot: String? +// ): ResponseEntity> { +// log.info { +// "추천 강제 새로고침 API 호출 - userId: ${member.getIdOrThrow()}, " + +// "type: $type, timeSlot: $timeSlot" +// } +// +// // String을 RecommendationType으로 변환 +// val recommendationType = try { +// RecommendationType.valueOf(type) +// } catch (e: IllegalArgumentException) { +// log.warn { "유효하지 않은 추천 타입 - userId: ${member.getIdOrThrow()}, type: $type" } +// val errorResponse: Map = mapOf( +// "success" to false, +// "error" to "유효하지 않은 추천 타입입니다. DAILY_CODE_MATCHING 또는 CODE_TIME을 사용하세요.", +// "validTypes" to RecommendationType.values().map { it.name } +// ) +// return ResponseEntity.badRequest().body(errorResponse) +// } +// +// return try { +// val result = recommendationService.forceRefreshRecommendation(member, recommendationType, timeSlot) +// +// val response: Map = mapOf( +// "success" to true, +// "type" to type, +// "timeSlot" to (timeSlot ?: ""), +// "result" to result, +// "refreshedAt" to System.currentTimeMillis() +// ) +// +// log.info { +// "추천 강제 새로고침 API 응답 - userId: ${member.getIdOrThrow()}, " + +// "type: $type, success: true" +// } +// +// ResponseEntity.ok(response) +// +// } catch (e: IllegalStateException) { +// log.warn { +// "추천 강제 새로고침 실패 - userId: ${member.getIdOrThrow()}, " + +// "type: $type, error: ${e.message}" +// } +// +// val errorResponse: Map = mapOf( +// "success" to false, +// "error" to (e.message ?: "강제 새로고침에 실패했습니다."), +// "type" to type, +// "timeSlot" to (timeSlot ?: "") +// ) +// +// ResponseEntity.badRequest().body(errorResponse) +// } +// } +// +// @GetMapping("/health") +// fun getSystemHealthCheck(): ResponseEntity> { +// log.info { "시스템 헬스체크 API 호출" } +// +// val healthCheck = recommendationService.getSystemHealthCheck() +// +// log.info { "시스템 헬스체크 API 응답 - status: ${healthCheck["systemStatus"]}" } +// +// return ResponseEntity.ok(healthCheck) +// } +// +// /** +// * 제외 로직 통계 조회 (디버깅용) +// * 어떤 사용자들이 왜 제외되었는지 확인할 수 있습니다. +// */ +// @GetMapping("/exclusion-stats") +// fun getExclusionStatistics( +// @LoginMember member: Member, +// @RequestParam(defaultValue = "DAILY_CODE_MATCHING") type: String +// ): ResponseEntity> { +// log.info { +// "제외 통계 조회 API 호출 - userId: ${member.getIdOrThrow()}, type: $type" +// } +// +// val recommendationType = try { +// RecommendationType.valueOf(type) +// } catch (e: IllegalArgumentException) { +// val errorResponse: Map = mapOf( +// "error" to "유효하지 않은 추천 타입입니다.", +// "validTypes" to RecommendationType.values().map { it.name } +// ) +// return ResponseEntity.badRequest().body(errorResponse) +// } +// +// val stats = recommendationService.getExclusionStatistics(member, recommendationType) +// +// log.info { +// "제외 통계 조회 API 응답 - userId: ${member.getIdOrThrow()}, " + +// "totalExcluded: ${(stats["excludedCounts"] as? Map<*, *>)?.get("total")}" +// } +// +// return ResponseEntity.ok(stats) +// } + + // ===== 기존 MemberController API 호환성을 위한 Deprecated 엔드포인트들 ===== + +// @GetMapping("/legacy/recommend") +// @Deprecated("Use GET /api/v1/recommendations/daily-code-matching instead", ReplaceWith("getDailyCodeMatching")) +// override fun legacyRecommendMembers( +// @LoginMember member: Member +// ): ResponseEntity { +// log.warn { "Deprecated API 호출 - /api/v1/recommendations/legacy/recommend - userId: ${member.getIdOrThrow()}" } +// +// // Deprecated API이므로 빈 응답 반환하고 새로운 API 사용을 권장 +// return ResponseEntity.ok(MemberRecommendResponse(emptyList())) +// } + +// @GetMapping("/legacy/all") +// @Deprecated("Use GET /api/v1/recommendations/random instead", ReplaceWith("getRandomMembers")) +// override fun legacyGetRandomMembers( +// @LoginMember member: Member, +// @RequestParam(defaultValue = "0") page: Int, +// @RequestParam(defaultValue = "8") size: Int +// ): ResponseEntity> { +// log.warn { +// "Deprecated API 호출 - /api/v1/recommendations/legacy/all - userId: ${member.getIdOrThrow()}, " + +// "page: $page, size: $size" +// } +// +// // Deprecated API이므로 새로운 API로 리디렉션 +// return getRandomMembers(member, page, size) +// } + + // ===== 새로운 통합 추천 API들 ===== +} diff --git a/src/main/kotlin/codel/recommendation/presentation/response/CodeTimeResponse.kt b/src/main/kotlin/codel/recommendation/presentation/response/CodeTimeResponse.kt new file mode 100644 index 00000000..91d5f1aa --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/response/CodeTimeResponse.kt @@ -0,0 +1,56 @@ +package codel.recommendation.presentation.response + +import codel.recommendation.domain.CodeTimeRecommendationResult +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * 코드타임 응답 DTO + */ +data class CodeTimeResponse( + @JsonProperty("timeSlot") + val timeSlot: String?, + + @JsonProperty("members") + val members: List, + + @JsonProperty("count") + val count: Int, + + @JsonProperty("isActiveTime") + val isActiveTime: Boolean, + + @JsonProperty("nextTimeSlot") + val nextTimeSlot: String?, + + @JsonProperty("message") + val message: String +) { + companion object { + fun from(result: CodeTimeRecommendationResult): CodeTimeResponse { + val message = when { + !result.isActiveTime -> { + if (result.nextTimeSlot != null) { + "현재 코드타임 시간대가 아닙니다. 다음 코드타임은 ${result.nextTimeSlot}입니다." + } else { + "현재 코드타임 시간대가 아닙니다." + } + } + result.hasRecommendations -> { + "코드타임 ${result.timeSlot}에 ${result.recommendationCount}명을 추천드립니다!" + } + else -> { + "현재 추천 가능한 사용자가 없습니다." + } + } + + return CodeTimeResponse( + timeSlot = result.timeSlot, + members = result.members.map { MemberSummary.from(it) }, + count = result.recommendationCount, + isActiveTime = result.isActiveTime, + nextTimeSlot = result.nextTimeSlot, + message = message + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/presentation/response/DailyCodeMatchingResponse.kt b/src/main/kotlin/codel/recommendation/presentation/response/DailyCodeMatchingResponse.kt new file mode 100644 index 00000000..975a7940 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/response/DailyCodeMatchingResponse.kt @@ -0,0 +1,32 @@ +package codel.recommendation.presentation.response + +import codel.member.domain.Member +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * 오늘의 코드매칭 응답 DTO + */ +data class DailyCodeMatchingResponse( + @JsonProperty("members") + val members: List, + + @JsonProperty("count") + val count: Int, + + @JsonProperty("message") + val message: String +) { + companion object { + fun from(members: List): DailyCodeMatchingResponse { + return DailyCodeMatchingResponse( + members = members.map { MemberSummary.from(it) }, + count = members.size, + message = if (members.isNotEmpty()) { + "오늘의 코드매칭 ${members.size}명을 추천드립니다!" + } else { + "현재 추천 가능한 사용자가 없습니다." + } + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/presentation/response/RecommendationOverviewResponse.kt b/src/main/kotlin/codel/recommendation/presentation/response/RecommendationOverviewResponse.kt new file mode 100644 index 00000000..d6b959e4 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/response/RecommendationOverviewResponse.kt @@ -0,0 +1,58 @@ +package codel.recommendation.presentation.response + +import codel.recommendation.business.RecommendationOverview +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * 추천 현황 종합 응답 DTO + */ +data class RecommendationOverviewResponse( + @JsonProperty("userId") + val userId: Long, + + @JsonProperty("hasDailyCodeMatching") + val hasDailyCodeMatching: Boolean, + + @JsonProperty("currentTimeSlot") + val currentTimeSlot: String?, + + @JsonProperty("nextTimeSlot") + val nextTimeSlot: String?, + + @JsonProperty("isCodeTimeActive") + val isCodeTimeActive: Boolean, + + @JsonProperty("dailyCodeMatchingStats") + val dailyCodeMatchingStats: Map, + + @JsonProperty("codeTimeStats") + val codeTimeStats: Map, + + @JsonProperty("allCodeTimeResults") + val allCodeTimeResults: Map, + + @JsonProperty("bucketStatistics") + val bucketStatistics: Map, + + @JsonProperty("totalUniqueRecommendationCount") + val totalUniqueRecommendationCount: Long +) { + companion object { + fun from(overview: RecommendationOverview): RecommendationOverviewResponse { + return RecommendationOverviewResponse( + userId = overview.userId, + hasDailyCodeMatching = overview.hasDailyCodeMatching, + currentTimeSlot = overview.currentTimeSlot, + nextTimeSlot = overview.nextTimeSlot, + isCodeTimeActive = overview.isCodeTimeActive, + dailyCodeMatchingStats = overview.dailyCodeMatchingStats, + codeTimeStats = overview.codeTimeStats, + allCodeTimeResults = overview.allCodeTimeResults.mapValues { (_, result) -> + CodeTimeData.from(result) + }, + bucketStatistics = overview.bucketStatistics, + totalUniqueRecommendationCount = overview.totalUniqueRecommendationCount + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/presentation/response/RecommendationResponse.kt b/src/main/kotlin/codel/recommendation/presentation/response/RecommendationResponse.kt new file mode 100644 index 00000000..10863f33 --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/response/RecommendationResponse.kt @@ -0,0 +1,125 @@ +package codel.recommendation.presentation.response + +import codel.member.domain.Member +import codel.recommendation.business.PrimaryRecommendation +import codel.recommendation.business.RecommendationResult +import codel.recommendation.domain.CodeTimeRecommendationResult +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * 통합 추천 응답 DTO + */ +data class RecommendationResponse( + @JsonProperty("primaryRecommendation") + val primaryRecommendation: String, + + @JsonProperty("recommendationMessage") + val recommendationMessage: String, + + @JsonProperty("dailyCodeMatching") + val dailyCodeMatching: DailyCodeMatchingData?, + + @JsonProperty("codeTime") + val codeTime: CodeTimeData? +) { + companion object { + fun from(result: RecommendationResult): RecommendationResponse { + return RecommendationResponse( + primaryRecommendation = when (result.primaryRecommendation) { + PrimaryRecommendation.DAILY_CODE_MATCHING -> "DAILY_CODE_MATCHING" + PrimaryRecommendation.CODE_TIME -> "CODE_TIME" + }, + recommendationMessage = result.recommendationMessage, + dailyCodeMatching = if (result.dailyCodeMatching.isNotEmpty()) { + DailyCodeMatchingData.from(result.dailyCodeMatching) + } else null, + codeTime = result.codeTimeResult?.let { CodeTimeData.from(it) } + ) + } + } +} + +/** + * 오늘의 코드매칭 데이터 + */ +data class DailyCodeMatchingData( + @JsonProperty("members") + val members: List, + + @JsonProperty("count") + val count: Int +) { + companion object { + fun from(members: List): DailyCodeMatchingData { + return DailyCodeMatchingData( + members = members.map { MemberSummary.from(it) }, + count = members.size + ) + } + } +} + +/** + * 코드타임 데이터 + */ +data class CodeTimeData( + @JsonProperty("timeSlot") + val timeSlot: String?, + + @JsonProperty("members") + val members: List, + + @JsonProperty("count") + val count: Int, + + @JsonProperty("isActiveTime") + val isActiveTime: Boolean, + + @JsonProperty("nextTimeSlot") + val nextTimeSlot: String? +) { + companion object { + fun from(result: CodeTimeRecommendationResult): CodeTimeData { + return CodeTimeData( + timeSlot = result.timeSlot, + members = result.members.map { MemberSummary.from(it) }, + count = result.recommendationCount, + isActiveTime = result.isActiveTime, + nextTimeSlot = result.nextTimeSlot + ) + } + } +} + +/** + * 회원 요약 정보 + */ +data class MemberSummary( + @JsonProperty("id") + val id: Long, + + @JsonProperty("codeName") + val codeName: String, + + @JsonProperty("age") + val age: Int?, + + @JsonProperty("region") + val region: String, + + @JsonProperty("profileImageUrl") + val profileImageUrl: String? +) { + companion object { + fun from(member: Member): MemberSummary { + val profile = member.getProfileOrThrow() + return MemberSummary( + id = member.getIdOrThrow(), + codeName = profile.codeName ?: "익명", + age = profile.getAge(), + region = "${profile.bigCity ?: "미설정"}-${profile.smallCity ?: "미설정"}", + profileImageUrl = profile.codeImage + ) + } + } +} diff --git a/src/main/kotlin/codel/recommendation/presentation/swagger/RecommendationSwagger.kt b/src/main/kotlin/codel/recommendation/presentation/swagger/RecommendationSwagger.kt new file mode 100644 index 00000000..f204cb2f --- /dev/null +++ b/src/main/kotlin/codel/recommendation/presentation/swagger/RecommendationSwagger.kt @@ -0,0 +1,107 @@ +package codel.recommendation.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.recommendation.presentation.response.* +import codel.member.presentation.response.MemberRecommendResponse +import codel.member.presentation.response.FullProfileResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 추천 시스템 API Swagger 문서 인터페이스 + */ +@Tag(name = "Recommendation", description = "추천 시스템 API - 지역 기반 버킷 정책과 시간대별 추천을 제공합니다") +interface RecommendationSwagger { + +// @Operation(summary = "통합 추천 조회", description = "사용자의 현재 상황에 맞는 최적의 추천을 제공합니다.") +// @ApiResponses( +// value = [ +// ApiResponse(responseCode = "200", description = "통합 추천 성공"), +// ApiResponse(responseCode = "401", description = "인증되지 않은 사용자") +// ] +// ) +// fun getRecommendation( +// @LoginMember member: Member, +// @Parameter(description = "코드타임을 우선적으로 원하는지 여부") +// @RequestParam(defaultValue = "false") preferCodeTime: Boolean +// ): ResponseEntity + + @Operation(summary = "오늘의 코드매칭 조회", description = "24시간 유지되는 오늘의 코드매칭을 조회합니다.") + fun getDailyCodeMatching( + @LoginMember member: Member + ): ResponseEntity + +// @Operation( +// summary = "랜덤 회원 추천 (파도타기)", +// description = "페이지네이션을 지원하는 랜덤 회원 추천을 제공합니다." +// ) +// fun getRandomMembers( +// @LoginMember member: Member, +// @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") page: Int, +// @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "8") size: Int +// ): ResponseEntity> + + @Operation(summary = "파도타기 조회", description = "현재 활성 시간대의 파도타기 추천인원을 조회합니다.") + fun getCodeTime( + @LoginMember member: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "8") size: Int + ): ResponseEntity> +// +// @Operation(summary = "특정 시간대 코드타임 조회", description = "특정 시간대의 코드타임을 조회합니다.") +// fun getCodeTimeBySlot( +// @LoginMember member: Member, +// @Parameter(description = "조회할 시간대") @PathVariable timeSlot: String +// ): ResponseEntity +// +// @Operation(summary = "추천 현황 종합 조회", description = "사용자의 추천 관련 모든 정보를 종합적으로 조회합니다.") +// fun getRecommendationOverview( +// @LoginMember member: Member +// ): ResponseEntity +// +// @Operation(summary = "추천 시스템 설정 조회", description = "현재 추천 시스템의 모든 설정값을 조회합니다.") +// fun getRecommendationSettings(): ResponseEntity> +// +// @Operation(summary = "추천 강제 새로고침", description = "특정 사용자의 추천을 강제로 새로 생성합니다.") +// fun forceRefreshRecommendation( +// @LoginMember member: Member, +// @Parameter(description = "추천 타입") @RequestParam type: String, +// @Parameter(description = "코드타임 시간대") @RequestParam(required = false) timeSlot: String? +// ): ResponseEntity> +// +// @Operation(summary = "시스템 헬스체크", description = "추천 시스템의 전반적인 상태를 확인합니다.") +// fun getSystemHealthCheck(): ResponseEntity> + + // ===== Legacy API 호환성 (Deprecated) ===== + +// @Deprecated("Use getDailyCodeMatching instead") +// @Operation( +// summary = "[Deprecated] 홈 코드 추천 매칭 조회", +// description = "⚠️ DEPRECATED: /api/v1/recommendations/daily-code-matching을 사용하세요." +// ) +// fun legacyRecommendMembers( +// @LoginMember member: Member +// ): ResponseEntity +// +// @Deprecated("Use getRandomMembers instead") +// @Operation( +// summary = "[Deprecated] 홈 파도타기 조회", +// description = "⚠️ DEPRECATED: /api/v1/recommendations/random을 사용하세요." +// ) +// fun legacyGetRandomMembers( +// @LoginMember member: Member, +// @RequestParam(defaultValue = "0") page: Int, +// @RequestParam(defaultValue = "8") size: Int +// ): ResponseEntity> + + // ===== 새로운 통합 추천 API ===== +} diff --git a/src/main/kotlin/codel/report/business/ReportAdminService.kt b/src/main/kotlin/codel/report/business/ReportAdminService.kt new file mode 100644 index 00000000..f949671d --- /dev/null +++ b/src/main/kotlin/codel/report/business/ReportAdminService.kt @@ -0,0 +1,211 @@ +package codel.report.business + +import codel.member.domain.Member +import codel.member.infrastructure.MemberJpaRepository +import codel.report.domain.Report +import codel.report.domain.ReportStatus +import codel.report.exception.ReportException +import codel.report.infrastructure.ReportJpaRepository +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class ReportAdminService( + private val reportJpaRepository: ReportJpaRepository, + private val memberJpaRepository: MemberJpaRepository +) { + + /** + * 신고 목록 조회 (필터링, 페이징) + */ + fun getReportsWithFilter( + keyword: String?, + status: String?, + startDate: String?, + endDate: String?, + pageable: Pageable + ): Page { + val reportStatus = status?.takeIf { it.isNotBlank() }?.let { ReportStatus.valueOf(it) } + + val startDateTime = startDate?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it).atStartOfDay() + } + + val endDateTime = endDate?.takeIf { it.isNotBlank() }?.let { + LocalDate.parse(it).atTime(23, 59, 59) + } + + val searchKeyword = keyword?.takeIf { it.isNotBlank() } + + return reportJpaRepository.findReportsWithFilter( + searchKeyword, + reportStatus, + startDateTime, + endDateTime, + pageable + ) + } + + /** + * 신고 상세 조회 + */ + fun getReportDetail(reportId: Long): Report { + return reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + } + + /** + * 통계: 전체 신고 수 + */ + fun getTotalReportsCount(): Long { + return reportJpaRepository.count() + } + + /** + * 통계: 상태별 신고 수 + */ + fun getReportCountByStatus(status: ReportStatus): Long { + return reportJpaRepository.countByStatus(status) + } + + /** + * 통계: 오늘 신고 수 + */ + fun getTodayReportsCount(): Long { + val today = LocalDate.now() + val startOfDay = today.atStartOfDay() + val endOfDay = today.atTime(23, 59, 59) + return reportJpaRepository.countByCreatedAtBetween(startOfDay, endOfDay) + } + + /** + * 통계: 이번 주 신고 수 + */ + fun getWeeklyReportsCount(): Long { + val now = LocalDateTime.now() + val weekAgo = now.minusWeeks(1) + return reportJpaRepository.countByCreatedAtBetween(weekAgo, now) + } + + /** + * 통계: 이번 달 신고 수 + */ + fun getMonthlyReportsCount(): Long { + val now = LocalDateTime.now() + val monthAgo = now.minusMonths(1) + return reportJpaRepository.countByCreatedAtBetween(monthAgo, now) + } + + /** + * 통계: 상태별 신고 수 (대시보드용) + */ + fun getReportStatusStats(): Map { + return mapOf( + "pending" to reportJpaRepository.countByStatus(ReportStatus.PENDING), + "inProgress" to reportJpaRepository.countByStatus(ReportStatus.IN_PROGRESS), + "resolved" to reportJpaRepository.countByStatus(ReportStatus.RESOLVED), + "dismissed" to reportJpaRepository.countByStatus(ReportStatus.DISMISSED), + "duplicate" to reportJpaRepository.countByStatus(ReportStatus.DUPLICATE) + ) + } + + /** + * 피신고자의 총 신고 횟수 조회 + */ + fun getReportedMemberReportCount(memberId: Long): Long { + val member = memberJpaRepository.findById(memberId) + .orElseThrow { ReportException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.") } + return reportJpaRepository.countByReported(member) + } + + /** + * 피신고자의 신고 이력 조회 + */ + fun getReportedMemberReports(memberId: Long, pageable: Pageable): Page { + val member = memberJpaRepository.findById(memberId) + .orElseThrow { ReportException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.") } + return reportJpaRepository.findByReportedOrderByCreatedAtDesc(member, pageable) + } + + /** + * 신고 많이 받은 사용자 TOP N + */ + fun getMostReportedMembers(days: Long = 30, limit: Int = 10): List> { + val since = LocalDateTime.now().minusDays(days) + val pageable = Pageable.ofSize(limit) + + val results = reportJpaRepository.findMostReportedMembers(since, pageable) + + return results.map { array -> + val member = array[0] as Member + val count = (array[1] as Long) + Pair(member, count) + } + } + + /** + * 신고 처리 상태 변경 + */ + @Transactional + fun updateReportStatus(reportId: Long, status: ReportStatus, note: String? = null) { + val report = reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + + report.updateStatus(status, note) + reportJpaRepository.save(report) + } + + /** + * 검토 시작 + */ + @Transactional + fun startProcessing(reportId: Long) { + val report = reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + + report.startProcessing() + reportJpaRepository.save(report) + } + + /** + * 처리 완료 + */ + @Transactional + fun resolveReport(reportId: Long, note: String? = null) { + val report = reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + + report.resolve(note) + reportJpaRepository.save(report) + } + + /** + * 반려 + */ + @Transactional + fun dismissReport(reportId: Long, note: String? = null) { + val report = reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + + report.dismiss(note) + reportJpaRepository.save(report) + } + + /** + * 중복 신고로 처리 + */ + @Transactional + fun markAsDuplicate(reportId: Long, note: String? = null) { + val report = reportJpaRepository.findByIdWithMembers(reportId) + ?: throw ReportException(HttpStatus.NOT_FOUND, "신고 내역을 찾을 수 없습니다.") + + report.markAsDuplicate(note) + reportJpaRepository.save(report) + } +} diff --git a/src/main/kotlin/codel/report/business/ReportService.kt b/src/main/kotlin/codel/report/business/ReportService.kt new file mode 100644 index 00000000..84f4b3ae --- /dev/null +++ b/src/main/kotlin/codel/report/business/ReportService.kt @@ -0,0 +1,97 @@ +package codel.report.business + +import codel.block.domain.BlockMemberRelation +import codel.block.infrastructure.BlockMemberRelationJpaRepository +import codel.chat.business.ChatService +import codel.chat.domain.ChatRoomStatus +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.presentation.response.SavedChatDto +import codel.member.domain.Member +import codel.member.exception.MemberException +import codel.member.infrastructure.MemberJpaRepository +import codel.report.domain.Report +import codel.report.exception.ReportException +import codel.report.infrastructure.ReportJpaRepository +import codel.signal.domain.SignalStatus +import codel.signal.infrastructure.SignalJpaRepository +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class ReportService( + val reportJpaRepository: ReportJpaRepository, + val memberJpaRepository: MemberJpaRepository, + val signalJpaRepository: SignalJpaRepository, + val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + val blockMemberRelationJpaRepository: BlockMemberRelationJpaRepository, + val chatService: ChatService +) { + fun report( + reporter: Member, + reportedId: Long, + reason: String + ): SavedChatDto? { + if (reporter.getIdOrThrow() == reportedId) { + throw ReportException(HttpStatus.BAD_REQUEST, "자기 자신을 신고할 수 없습니다.") + } + + val reported = memberJpaRepository.findById(reportedId) + .orElseThrow { MemberException(HttpStatus.BAD_REQUEST, "신고 대상을 찾을 수 없습니다.") } + + // 1. 신고 내역 저장 + val report = Report(reporter = reporter, reported = reported, reason = reason) + reportJpaRepository.save(report) + + // 2. 시그널 확인 + val signalFromReporter = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(reporter, reported) + val signalToReporter = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(reported, reporter) + + // 3. 채팅방 확인 + val chatRoom = chatService.findChatRoomBetweenMembers(reporter, reported) + + return when { + // 채팅방이 있는 경우 -> 시스템 메시지 + WebSocket 전송 + chatRoom != null -> { + if (chatRoom.status != ChatRoomStatus.DISABLED) { + chatRoom.closeConversation() + + // 차단 처리 + val blockMemberRelation = BlockMemberRelation(blockerMember = reporter, blockedMember = reported) + blockMemberRelationJpaRepository.save(blockMemberRelation) + + // 시스템 메시지 생성 및 응답 구성 + chatService.createCloseConversationMessage(chatRoom, reporter, reported) + } else { + null + } + } + // 시그널 전송 + 채팅방 없는 경우 -> 신고 + 차단 + 시그널 REJECT 상태 + (signalFromReporter != null || signalToReporter != null) -> { + // 차단 처리 + val blockMemberRelation = BlockMemberRelation(blockerMember = reporter, blockedMember = reported) + blockMemberRelationJpaRepository.save(blockMemberRelation) + + // 시그널 REJECT 상태로 변경 + signalFromReporter?.let { + if (it.senderStatus != SignalStatus.REJECTED) { + it.reject() + } + } + signalToReporter?.let { + if (it.receiverStatus != SignalStatus.REJECTED) { + it.reject() + } + } + null + } + // 시그널 미전송 + 채팅방 없는 경우 -> 신고 + 차단 + else -> { + val blockMemberRelation = BlockMemberRelation(blockerMember = reporter, blockedMember = reported) + blockMemberRelationJpaRepository.save(blockMemberRelation) + null + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/report/domain/Report.kt b/src/main/kotlin/codel/report/domain/Report.kt new file mode 100644 index 00000000..c5c11c6c --- /dev/null +++ b/src/main/kotlin/codel/report/domain/Report.kt @@ -0,0 +1,71 @@ +package codel.report.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import jakarta.persistence.* + +@Entity +class Report( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id : Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id") + val reporter: Member, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_id") + val reported: Member, + + @Column(columnDefinition = "TEXT") + val reason : String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var status: ReportStatus = ReportStatus.PENDING, + + @Column(columnDefinition = "TEXT") + var adminNote: String? = null, // 관리자 메모 + + var processedAt: java.time.LocalDateTime? = null // 처리 일시 +) : BaseTimeEntity() { + + /** + * 신고 처리 상태 변경 + */ + fun updateStatus(newStatus: ReportStatus, note: String? = null) { + this.status = newStatus + this.adminNote = note + if (newStatus != ReportStatus.PENDING && newStatus != ReportStatus.IN_PROGRESS) { + this.processedAt = java.time.LocalDateTime.now() + } + } + + /** + * 처리 중으로 변경 + */ + fun startProcessing() { + this.status = ReportStatus.IN_PROGRESS + } + + /** + * 처리 완료 + */ + fun resolve(note: String? = null) { + updateStatus(ReportStatus.RESOLVED, note) + } + + /** + * 반려 + */ + fun dismiss(note: String? = null) { + updateStatus(ReportStatus.DISMISSED, note) + } + + /** + * 중복으로 처리 + */ + fun markAsDuplicate(note: String? = null) { + updateStatus(ReportStatus.DUPLICATE, note) + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/report/domain/ReportStatus.kt b/src/main/kotlin/codel/report/domain/ReportStatus.kt new file mode 100644 index 00000000..1bace195 --- /dev/null +++ b/src/main/kotlin/codel/report/domain/ReportStatus.kt @@ -0,0 +1,12 @@ +package codel.report.domain + +/** + * 신고 처리 상태 + */ +enum class ReportStatus(val description: String) { + PENDING("미처리"), // 신고 접수됨, 아직 검토 안함 + IN_PROGRESS("검토중"), // 관리자가 검토 중 + RESOLVED("처리완료"), // 조치 완료 (경고/정지 등) + DISMISSED("반려"), // 부적절한 신고로 반려 + DUPLICATE("중복신고") // 이미 처리된 동일 신고 +} diff --git a/src/main/kotlin/codel/report/exception/ReportException.kt b/src/main/kotlin/codel/report/exception/ReportException.kt new file mode 100644 index 00000000..72a40d0b --- /dev/null +++ b/src/main/kotlin/codel/report/exception/ReportException.kt @@ -0,0 +1,9 @@ +package codel.report.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class ReportException( + status : HttpStatus, + message : String +) : CodelException(status, message) \ No newline at end of file diff --git a/src/main/kotlin/codel/report/infrastructure/ReportJpaRepository.kt b/src/main/kotlin/codel/report/infrastructure/ReportJpaRepository.kt new file mode 100644 index 00000000..453156a5 --- /dev/null +++ b/src/main/kotlin/codel/report/infrastructure/ReportJpaRepository.kt @@ -0,0 +1,123 @@ +package codel.report.infrastructure + +import codel.member.domain.Member +import codel.report.domain.Report +import codel.report.domain.ReportStatus +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.time.LocalDateTime + +interface ReportJpaRepository : JpaRepository { + + /** + * 상태별 신고 개수 조회 + */ + fun countByStatus(status: ReportStatus): Long + + /** + * 특정 기간 내 신고 개수 조회 + */ + fun countByCreatedAtBetween(startDate: LocalDateTime, endDate: LocalDateTime): Long + + /** + * 피신고자별 신고 횟수 조회 + */ + fun countByReported(reported: Member): Long + + /** + * 피신고자의 신고 이력 조회 (FETCH JOIN 추가) + */ + @Query(""" + SELECT r FROM Report r + JOIN FETCH r.reporter + JOIN FETCH r.reported + WHERE r.reported = :reported + ORDER BY r.createdAt DESC + """) + fun findByReportedOrderByCreatedAtDesc(@Param("reported") reported: Member, pageable: Pageable): Page + + /** + * 상태별 신고 목록 조회 (FETCH JOIN 추가) + */ + @Query(""" + SELECT r FROM Report r + JOIN FETCH r.reporter + JOIN FETCH r.reported + WHERE r.status = :status + ORDER BY r.createdAt DESC + """) + fun findByStatusOrderByCreatedAtDesc(@Param("status") status: ReportStatus, pageable: Pageable): Page + + /** + * 전체 신고 목록 조회 (FETCH JOIN 추가) + */ + @Query(""" + SELECT r FROM Report r + JOIN FETCH r.reporter + JOIN FETCH r.reported + ORDER BY r.createdAt DESC + """) + fun findAllByOrderByCreatedAtDesc(pageable: Pageable): Page + + /** + * ID로 신고 조회 (FETCH JOIN 추가) + */ + @Query(""" + SELECT r FROM Report r + JOIN FETCH r.reporter reporter + JOIN FETCH r.reported reported + LEFT JOIN FETCH reporter.profile + LEFT JOIN FETCH reported.profile + WHERE r.id = :id + """) + fun findByIdWithMembers(@Param("id") id: Long): Report? + + /** + * 복합 필터링 쿼리 + * - 키워드: 신고 사유, 신고자 이름, 피신고자 이름 + * - 상태 필터 + * - 날짜 범위 + */ + @Query(""" + SELECT r FROM Report r + JOIN FETCH r.reporter reporter + JOIN FETCH r.reported reported + LEFT JOIN reporter.profile reporterProfile + LEFT JOIN reported.profile reportedProfile + WHERE (:keyword IS NULL + OR r.reason LIKE CONCAT('%', :keyword, '%') + OR reporter.email LIKE CONCAT('%', :keyword, '%') + OR reported.email LIKE CONCAT('%', :keyword, '%') + OR reporterProfile.codeName LIKE CONCAT('%', :keyword, '%') + OR reportedProfile.codeName LIKE CONCAT('%', :keyword, '%')) + AND (:status IS NULL OR r.status = :status) + AND (:startDate IS NULL OR r.createdAt >= :startDate) + AND (:endDate IS NULL OR r.createdAt <= :endDate) + ORDER BY r.createdAt DESC + """) + fun findReportsWithFilter( + @Param("keyword") keyword: String?, + @Param("status") status: ReportStatus?, + @Param("startDate") startDate: LocalDateTime?, + @Param("endDate") endDate: LocalDateTime?, + pageable: Pageable + ): Page + + /** + * 신고 많이 받은 사용자 TOP N + */ + @Query(""" + SELECT r.reported, COUNT(r) as reportCount + FROM Report r + WHERE r.createdAt >= :since + GROUP BY r.reported + ORDER BY reportCount DESC + """) + fun findMostReportedMembers( + @Param("since") since: LocalDateTime, + pageable: Pageable + ): List> +} \ No newline at end of file diff --git a/src/main/kotlin/codel/report/presentation/ReportController.kt b/src/main/kotlin/codel/report/presentation/ReportController.kt new file mode 100644 index 00000000..b191da4a --- /dev/null +++ b/src/main/kotlin/codel/report/presentation/ReportController.kt @@ -0,0 +1,77 @@ +package codel.report.presentation + +import codel.config.argumentresolver.LoginMember +import codel.member.business.MemberService +import codel.member.domain.Member +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import codel.report.business.ReportService +import codel.report.presentation.request.ReportRequest +import codel.report.presentation.swagger.ReportControllerSwagger +import org.springframework.http.ResponseEntity +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@RestController +@RequestMapping("/v1/reports") +class ReportController( + val reportService: ReportService, + val memberService : MemberService, + val asyncNotificationService: IAsyncNotificationService, + val messagingTemplate: SimpMessagingTemplate, +) : ReportControllerSwagger { + + @PostMapping + override fun reportMember( + @LoginMember member: Member, + @RequestBody reportRequest: ReportRequest + ): ResponseEntity { + val savedChatDto = reportService.report(member, reportRequest.reportedId, reportRequest.reason) + + // 채팅방이 있었고 시스템 메시지가 생성된 경우에만 WebSocket 전송 + savedChatDto?.let { responseDto -> + // 상대방에게는 읽지 않은 수가 증가된 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${responseDto.partner.id}", + responseDto.partnerChatRoomResponse, + ) + + // 발송자에게는 본인 기준 채팅방 정보 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${member.id}", + responseDto.requesterChatRoomResponse, + ) + + // 채팅방 구독자들에게 실시간 메시지 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/${responseDto.requesterChatRoomResponse.chatRoomId}", + responseDto.chatResponse + ) + } + + // 디스코드 알림은 채팅방 존재 여부와 관계없이 항상 전송 + val reportedMember = memberService.findMember(reportRequest.reportedId) + asyncNotificationService.sendAsync( + notification = + Notification( + type = NotificationType.DISCORD, + targetId = member.getProfileOrThrow().toString(), + title = "🚨 신고 접수 알림", + body = buildString { + append("👮‍♀️ 신고자: ${member.getProfileOrThrow().getCodeNameOrThrow()}\n") + append("🎯 피신고자: ${reportedMember.getProfileOrThrow().getCodeNameOrThrow()}\n") + append("🗓 신고 시각: ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}\n") + append("💬 신고 사유: ${reportRequest.reason.ifBlank { "미입력" }}") + }, + ), + ) + + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/report/presentation/request/ReportRequest.kt b/src/main/kotlin/codel/report/presentation/request/ReportRequest.kt new file mode 100644 index 00000000..b679d2b2 --- /dev/null +++ b/src/main/kotlin/codel/report/presentation/request/ReportRequest.kt @@ -0,0 +1,7 @@ +package codel.report.presentation.request + +class ReportRequest( + val reportedId : Long, + val reason : String +) { +} \ No newline at end of file diff --git a/src/main/kotlin/codel/report/presentation/swagger/ReportControllerSwagger.kt b/src/main/kotlin/codel/report/presentation/swagger/ReportControllerSwagger.kt new file mode 100644 index 00000000..04ab7613 --- /dev/null +++ b/src/main/kotlin/codel/report/presentation/swagger/ReportControllerSwagger.kt @@ -0,0 +1,31 @@ +package codel.report.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.report.presentation.request.ReportRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RequestBody + +@Tag(name = "Report", description = "신고 관련 API") +interface ReportControllerSwagger { + @Operation( + summary = "회원 신고 조회", + description = "다른 회원을 신고할 수 있습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "회원 신고 성공"), + ApiResponse(responseCode = "400", description = "요청 값이 잘못됨"), + ApiResponse(responseCode = "500", description = "서버 내부 오류"), + ], + ) + fun reportMember( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestBody reportRequest: ReportRequest, + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/signal/business/SignalService.kt b/src/main/kotlin/codel/signal/business/SignalService.kt new file mode 100644 index 00000000..ce9deeff --- /dev/null +++ b/src/main/kotlin/codel/signal/business/SignalService.kt @@ -0,0 +1,238 @@ +package codel.signal.business + +import codel.chat.business.ChatService +import codel.chat.domain.ChatRoomStatus +import codel.chat.infrastructure.ChatRoomMemberJpaRepository +import codel.chat.presentation.response.ChatRoomResponse +import codel.config.Loggable +import codel.member.domain.Member +import codel.member.domain.MemberRepository +import codel.member.presentation.response.UnlockedMemberProfileResponse +import codel.notification.business.IAsyncNotificationService +import codel.notification.domain.Notification +import codel.notification.domain.NotificationType +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus +import codel.signal.exception.SignalException +import codel.signal.infrastructure.SignalJpaRepository +import codel.signal.presentation.response.AcceptSignalResult +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SignalService( + private val memberRepository: MemberRepository, + private val signalJpaRepository: SignalJpaRepository, + private val chatRoomMemberJpaRepository: ChatRoomMemberJpaRepository, + private val chatService: ChatService, + private val asyncNotificationService: IAsyncNotificationService +) : Loggable { + @Transactional + fun sendSignal(fromMember: Member, toMemberId: Long, message: String): Signal { + validateNotSelf(fromMember.getIdOrThrow(), toMemberId) + val toMember = memberRepository.findMember(toMemberId) + val lastSignal = signalJpaRepository.findTopByFromMemberAndToMemberOrderByIdDesc(fromMember, toMember) + lastSignal?.validateSendable() + + val signal = Signal(fromMember = fromMember, toMember = toMember, message = message) + val savedSignal = signalJpaRepository.save(signal) + + // 알림 전송 + sendSignalNotification(toMember, fromMember) + + return savedSignal + } + + private fun sendSignalNotification(receiver: Member, sender: Member) { + receiver.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "새로운 시그널이 도착했어요 🔔", + body = "${sender.getProfileOrThrow().getCodeNameOrThrow()}님이 시그널을 보냈습니다." + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 시그널 알림 전송 성공 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}" } + } else { + log.warn { "❌ 시그널 알림 전송 실패 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 시그널 알림 전송 예외 발생 - 수신자: ${receiver.getIdOrThrow()}, 발신자: ${sender.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 알림을 전송하지 않음 - 수신자: ${receiver.getIdOrThrow()}" } + } + } + + private fun validateNotSelf(fromMemberId: Long, toMemberId: Long) { + if (fromMemberId == toMemberId) { + throw SignalException(HttpStatus.BAD_REQUEST, "자기 자신에게는 시그널을 보낼 수 없습니다.") + } + } + + @Transactional(readOnly = true) + fun getReceivedSignals( + me: Member, + page: Int, + size: Int + ): Page { + val pageable = PageRequest.of(page, size) + val receivedSignals = signalJpaRepository.findByToMemberAndStatus(me, SignalStatus.PENDING) + return PageImpl(receivedSignals, pageable, receivedSignals.size.toLong()) + } + + + @Transactional(readOnly = true) + fun getSendSignalByMe( + me: Member, + page: Int, + size: Int + ): Page { + val pageable = PageRequest.of(page, size) + val sendSignals = signalJpaRepository.findByFromMemberAndStatus(me, SignalStatus.PENDING) + return PageImpl(sendSignals, pageable, sendSignals.size.toLong()) + } + + + @Transactional + fun acceptSignal( + me: Member, + id: Long + ) : AcceptSignalResult { + val findSignal = signalJpaRepository.findById(id) + .orElseThrow { SignalException(HttpStatus.NOT_FOUND, "해당 시그널을 찾을 수 없습니다.") } + + validateMySignal(findSignal, me) + findSignal.accept() + + val approvedSignal = signalJpaRepository.save(findSignal) + + val partner = findSignal.fromMember + + val result = chatService.createInitialChatRoom(me, partner, approvedSignal.message) + + // 매칭 성공 알림 전송 (양쪽 모두에게) + sendMatchingSuccessNotification(me, partner) + + return AcceptSignalResult( + approverChatRoomResponse = result.approverChatRoomResponse, + partnerChatRoomResponse = result.senderChatRoomResponse, + partner = partner + ) + } + + private fun sendMatchingSuccessNotification(accepter: Member, sender: Member) { + // 승인자(수신자)에게 알림 + sendMatchingNotification(accepter, sender) + + // 발신자에게 알림 + sendMatchingNotification(sender, accepter) + } + + private fun sendMatchingNotification(receiver: Member, partner: Member) { + receiver.fcmToken?.let { token -> + val notification = Notification( + type = NotificationType.MOBILE, + targetId = token, + title = "코드매칭 성공! 채팅방이 열렸어요! 🎉", + body = "${partner.getProfileOrThrow().getCodeNameOrThrow()}님과 대화를 시작해보세요!" + ) + + // 비동기 알림 전송으로 변경 + asyncNotificationService.sendAsync(notification) + .thenAccept { result -> + if (result.success) { + log.info { "✅ 매칭 알림 전송 성공 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}" } + } else { + log.warn { "❌ 매칭 알림 전송 실패 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}, 사유: ${result.error}" } + } + } + .exceptionally { e -> + log.warn(e) { "❌ 매칭 알림 전송 예외 발생 - 수신자: ${receiver.getIdOrThrow()}, 상대방: ${partner.getIdOrThrow()}" } + null + } + } ?: run { + log.info { "ℹ️ FCM 토큰이 없어 매칭 알림을 전송하지 않음 - 수신자: ${receiver.getIdOrThrow()}" } + } + } + + private fun validateMySignal(findSignal: Signal, me: Member) { + if (findSignal.toMember.id != me.id) { + throw SignalException(HttpStatus.BAD_REQUEST, "내게 온 시그널만 수락할 수 있어요.") + } + } + + @Transactional + fun rejectSignal( + me: Member, + id: Long + ) { + val findSignal = signalJpaRepository.findById(id) + .orElseThrow { SignalException(HttpStatus.NOT_FOUND, "해당 시그널을 찾을 수 없습니다.") } + + validateMySignal(findSignal, me) + findSignal.reject() + signalJpaRepository.save(findSignal) + } + + + @Transactional(readOnly = true) + fun getAcceptedSignals( + me: Member, + page: Int, + size: Int + ): Page { + val pageable = PageRequest.of(page, size) + val acceptedSignals = signalJpaRepository.findByMemberAndStatus(me, SignalStatus.APPROVED) + return PageImpl(acceptedSignals, pageable, acceptedSignals.size.toLong()) + } + + @Transactional(readOnly = true) + fun getUnlockedSignal(member: Member, page: Int, size: Int): Page { + val pageable = PageRequest.of(page, size) + + val chatRoomMembers = + chatRoomMemberJpaRepository.findUnlockedOpponentsWithProfile(member, ChatRoomStatus.UNLOCKED, pageable) + // unlockedAt이 null인 경우는 제외하고 변환 + val responses = chatRoomMembers.content.mapNotNull { chatRoomMember -> + val unlockedAt = chatRoomMember.chatRoom.unlockedAt + if (unlockedAt == null) { + // continue 역할 — 해당 항목은 null을 반환하여 결과에서 제거됨 + null + } else { + UnlockedMemberProfileResponse.toResponse(chatRoomMember.member, unlockedAt) + } + } + + // PageImpl로 수동 변환 (content 필터링했기 때문) + return PageImpl(responses, pageable, responses.size.toLong()) + } + + @Transactional + fun hideSignal(me: Member, id: Long) { + val findSignal = signalJpaRepository.findById(id) + .orElseThrow { SignalException(HttpStatus.NOT_FOUND, "해당 시그널을 찾을 수 없습니다.") } + + findSignal.hide(me.getIdOrThrow()) + } + + @Transactional + fun hideSignals(me: Member, signalIds : List){ + signalIds.forEach { signalId -> + val findSignal = signalJpaRepository.findById(signalId) + .orElseThrow { SignalException(HttpStatus.BAD_REQUEST, "해당 시그널을 찾을 수 없습니다.") } + + findSignal.hide(me.getIdOrThrow()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/domain/Signal.kt b/src/main/kotlin/codel/signal/domain/Signal.kt new file mode 100644 index 00000000..a2b36d37 --- /dev/null +++ b/src/main/kotlin/codel/signal/domain/Signal.kt @@ -0,0 +1,107 @@ +package codel.signal.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import codel.signal.exception.SignalException +import jakarta.persistence.* +import org.springframework.http.HttpStatus +import java.time.LocalDateTime + +@Entity +@Table(name = "member_signal") +class Signal( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + @ManyToOne(fetch = FetchType.LAZY) + val fromMember: Member, + @ManyToOne(fetch = FetchType.LAZY) + val toMember: Member, + + var message : String = "", // 상대방(승인자) 질문에 대한 답변 + @Enumerated(EnumType.STRING) + var senderStatus: SignalStatus = SignalStatus.PENDING, + + @Enumerated(EnumType.STRING) + var receiverStatus: SignalStatus = SignalStatus.PENDING, + + + + ) : BaseTimeEntity() { + fun getIdOrThrow(): Long = id ?: throw SignalException(HttpStatus.BAD_REQUEST, "id가 없는 시그널 입니다.") + + fun validateSendable(now: LocalDateTime = LocalDateTime.now()) { + if (!canSendNewSignal(now)) { + when (senderStatus) { + SignalStatus.PENDING, SignalStatus.APPROVED, SignalStatus.PENDING_HIDDEN -> + throw SignalException(HttpStatus.BAD_REQUEST, "이미 시그널을 보낸 상대입니다.") + + SignalStatus.REJECTED -> + throw SignalException(HttpStatus.BAD_REQUEST, "거절된 상대에게는 7일 후에 다시 시그널을 보낼 수 있습니다.") + + SignalStatus.NONE -> return + } + } + } + + private fun canSendNewSignal(now: LocalDateTime = LocalDateTime.now()): Boolean { + return when (senderStatus) { + SignalStatus.PENDING, SignalStatus.APPROVED, SignalStatus.PENDING_HIDDEN -> false + SignalStatus.REJECTED -> updatedAt.plusDays(7).isBefore(now) + else -> true + } + } + + fun accept() { + validateChangeAcceptable() + senderStatus = SignalStatus.APPROVED + receiverStatus = SignalStatus.APPROVED + } + + fun reject() { + validateChangeAcceptable() + senderStatus = SignalStatus.REJECTED + receiverStatus = SignalStatus.REJECTED + } + + private fun validateChangeAcceptable() { + senderStatus.changeBlockedMessage()?.let { msg -> + throw SignalException(HttpStatus.BAD_REQUEST, msg) + } + } + + fun hide(memberId: Long) { + validateAccessRight(memberId) + if (memberId == fromMember.id) { + validateSenderHideAcceptable() + senderStatus = when (senderStatus) { + SignalStatus.PENDING -> SignalStatus.PENDING_HIDDEN + else -> throw SignalException(HttpStatus.BAD_REQUEST, "숨김처리가 불가능합니다.") + } + } else { + validateReceiverHideAcceptable() + receiverStatus = when (receiverStatus) { + SignalStatus.PENDING -> SignalStatus.PENDING_HIDDEN + else -> throw SignalException(HttpStatus.BAD_REQUEST, "숨김처리가 불가능합니다.") + } + } + } + + private fun validateAccessRight(memberId: Long) { + if (memberId != fromMember.id && memberId != toMember.id) { + throw SignalException(HttpStatus.BAD_REQUEST, "자신과 관련된 시그널이 아닙니다.") + } + } + + private fun validateSenderHideAcceptable() { + senderStatus.canHide()?.let { msg -> + throw SignalException(HttpStatus.BAD_REQUEST, msg) + } + } + + private fun validateReceiverHideAcceptable() { + senderStatus.canHide()?.let { msg -> + throw SignalException(HttpStatus.BAD_REQUEST, msg) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/domain/SignalStatus.kt b/src/main/kotlin/codel/signal/domain/SignalStatus.kt new file mode 100644 index 00000000..b76bf747 --- /dev/null +++ b/src/main/kotlin/codel/signal/domain/SignalStatus.kt @@ -0,0 +1,22 @@ +package codel.signal.domain + +enum class SignalStatus(val statusName: String) { + NONE("상태없음"), + PENDING("매칭대기"), + PENDING_HIDDEN("매칭대기_숨김"), + APPROVED("매칭성공"), + REJECTED("매칭거절"); + + + fun changeBlockedMessage(): String? = when (this) { + REJECTED -> "이미 시그널 거절된 상대입니다." + APPROVED -> "이미 시그널 승인된 상대입니다." + else -> null + } + + fun canHide(): String? = when (this) { + REJECTED -> "거절된 시그널은 숨김처리가 불가능합니다." + PENDING_HIDDEN -> "이미 숨김처리가 되었습니다." + else -> null + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/exception/SignalException.kt b/src/main/kotlin/codel/signal/exception/SignalException.kt new file mode 100644 index 00000000..72d352d2 --- /dev/null +++ b/src/main/kotlin/codel/signal/exception/SignalException.kt @@ -0,0 +1,9 @@ +package codel.signal.exception + +import codel.config.exception.CodelException +import org.springframework.http.HttpStatus + +class SignalException( + status: HttpStatus, + message: String, +) : CodelException(status, message) diff --git a/src/main/kotlin/codel/signal/infrastructure/SignalJpaRepository.kt b/src/main/kotlin/codel/signal/infrastructure/SignalJpaRepository.kt new file mode 100644 index 00000000..6a9bc233 --- /dev/null +++ b/src/main/kotlin/codel/signal/infrastructure/SignalJpaRepository.kt @@ -0,0 +1,203 @@ +package codel.signal.infrastructure + +import codel.member.domain.Member +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface SignalJpaRepository : JpaRepository { + + /** + * 회원 탈퇴 시 해당 회원이 보낸 모든 시그널을 REJECTED로 변경 + */ + @Modifying + @Query(""" + UPDATE Signal s + SET s.senderStatus = 'REJECTED', s.receiverStatus = 'REJECTED' + WHERE s.fromMember = :member + """) + fun rejectAllSignalsFromMember(@Param("member") member: Member): Int + + /** + * 회원 탈퇴 시 해당 회원이 받은 모든 시그널을 REJECTED로 변경 + */ + @Modifying + @Query(""" + UPDATE Signal s + SET s.senderStatus = 'REJECTED', s.receiverStatus = 'REJECTED' + WHERE s.toMember = :member + """) + fun rejectAllSignalsToMember(@Param("member") member: Member): Int + fun findTopByFromMemberAndToMemberOrderByIdDesc(fromMember: Member, toMember: Member): Signal? + + @Query(""" + SELECT s FROM Signal s + JOIN FETCH s.fromMember fm + JOIN FETCH fm.profile + JOIN FETCH fm.profile.representativeQuestion + JOIN FETCH s.toMember tm + WHERE s.toMember = :member + AND s.receiverStatus = :status + AND NOT EXISTS ( + SELECT 1 FROM BlockMemberRelation b + WHERE b.blockerMember = :member AND b.blockedMember.id = fm.id + AND b.status = 'BLOCKED') + """) + fun findByToMemberAndStatus(member: Member, @Param("status") signalStatus: SignalStatus): List + @Query( + """ + SELECT DISTINCT s FROM Signal s + JOIN FETCH s.fromMember fm + JOIN FETCH fm.profile + JOIN FETCH fm.profile.representativeQuestion + JOIN FETCH s.toMember tm + JOIN FETCH tm.profile + WHERE s.senderStatus = :status + AND (s.fromMember = :member OR s.toMember = :member) + """ + ) + fun findByMemberAndStatus(member: Member, @Param("status") signalStatus: SignalStatus): List + + @Query( + """ + SELECT DISTINCT s FROM Signal s + JOIN FETCH s.fromMember fm + JOIN FETCH fm.profile + JOIN FETCH fm.profile.representativeQuestion + JOIN FETCH s.toMember tm + JOIN FETCH tm.profile + JOIN FETCH tm.profile.representativeQuestion + WHERE s.fromMember = :member + AND s.senderStatus = :status + AND NOT EXISTS ( + SELECT 1 FROM BlockMemberRelation b + WHERE b.blockerMember = :member AND b.blockedMember.id = tm.id + AND b.status = 'BLOCKED' + ) + """ + ) + fun findByFromMemberAndStatus(member: Member, @Param("status") signalStatus: SignalStatus): List + + @Query( + """ + SELECT s.toMember.id FROM Signal s + WHERE s.fromMember = :fromMember + AND s.toMember IN :candidates + AND ( + (s.senderStatus = 'REJECTED' AND s.updatedAt >= :sevenDaysAgo) + OR s.senderStatus IN ('ACCEPTED', 'ACCEPTED_HIDDEN', 'PENDING', 'PENDING_HIDDEN') + ) + AND s.id IN ( + SELECT MAX(s2.id) FROM Signal s2 + WHERE s2.fromMember = :fromMember AND s2.toMember IN :candidates + GROUP BY s2.toMember + ) + """ + ) + fun findExcludedToMemberIds( + @Param("fromMember") fromMember: Member, + @Param("candidates") candidates: List, + @Param("sevenDaysAgo") sevenDaysAgo: LocalDateTime + ): List + + @Query( + """ + SELECT s.fromMember.id FROM Signal s + WHERE s.toMember = :toMember + AND s.fromMember IN :candidates + AND ( + (s.senderStatus = 'REJECTED' AND s.updatedAt >= :sevenDaysAgo AND s.updatedAt < :todayMidnight) + OR (s.senderStatus IN ('ACCEPTED', 'PENDING', 'PENDING_HIDDEN') AND s.updatedAt < :todayMidnight) + ) + AND s.id IN ( + SELECT MAX(s2.id) FROM Signal s2 + WHERE s2.toMember = :toMember AND s2.fromMember IN :candidates + GROUP BY s2.toMember + ) + """ + ) + fun findExcludedFromMemberIdsAtMidnight( + @Param("toMember") toMember: Member, + @Param("candidates") candidates: List, + @Param("sevenDaysAgo") sevenDaysAgo: LocalDateTime, + @Param("todayMidnight") todayMidnight: LocalDateTime + ): List + + + @Query( + """ + SELECT s.toMember.id FROM Signal s + WHERE s.fromMember = :fromMember + AND s.fromMember IN :candidates + AND ( + (s.senderStatus = 'REJECTED' AND s.updatedAt >= :sevenDaysAgo AND s.updatedAt < :todayMidnight) + OR (s.senderStatus IN ('ACCEPTED', 'PENDING', 'PENDING_HIDDEN') AND s.updatedAt < :todayMidnight) + ) + AND s.id IN ( + SELECT MAX(s2.id) FROM Signal s2 + WHERE s2.fromMember = :fromMember AND s2.toMember IN :candidates + GROUP BY s2.fromMember + ) + """ + ) + fun findExcludedToMemberIdsAtMidnight( + @Param("fromMember") fromMember: Member, + @Param("candidates") candidates: List, + @Param("sevenDaysAgo") sevenDaysAgo: LocalDateTime, + @Param("todayMidnight") todayMidnight: LocalDateTime + ): List + + @Query( + """ + SELECT s.fromMember.id + FROM Signal s + WHERE s.toMember = :toMember + AND s.id = ( + SELECT MAX(s2.id) + FROM Signal s2 + WHERE s2.toMember = :toMember + AND s2.fromMember = s.fromMember + ) + AND ( + (s.senderStatus = 'REJECTED' AND s.updatedAt >= :sevenDaysAgo AND s.updatedAt < :todayMidnight) + OR (s.senderStatus IN ('ACCEPTED', 'PENDING', 'PENDING_HIDDEN') AND s.updatedAt < :todayMidnight) + ) + """ + ) + fun findExcludedFromMemberIdsAtMidnight( + @Param("toMember") toMember: Member, + @Param("sevenDaysAgo") sevenDaysAgo: LocalDateTime, + @Param("todayMidnight") todayMidnight: LocalDateTime + ): List + + + + @Query( + """ + SELECT s.toMember.id + FROM Signal s + WHERE s.fromMember = :fromMember + AND s.id = ( + SELECT MAX(s2.id) + FROM Signal s2 + WHERE s2.fromMember = :fromMember + AND s2.toMember = s.toMember + ) + AND ( + (s.senderStatus = 'REJECTED' AND s.updatedAt >= :sevenDaysAgo AND s.updatedAt < :todayMidnight) + OR (s.senderStatus IN ('ACCEPTED', 'PENDING', 'PENDING_HIDDEN') AND s.updatedAt < :todayMidnight) + ) + """ + ) + fun findExcludedToMemberIdsAtMidnight( + @Param("fromMember") fromMember: Member, + @Param("sevenDaysAgo") sevenDaysAgo: LocalDateTime, + @Param("todayMidnight") todayMidnight: LocalDateTime + ): List +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/presentation/SignalController.kt b/src/main/kotlin/codel/signal/presentation/SignalController.kt new file mode 100644 index 00000000..992be55c --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/SignalController.kt @@ -0,0 +1,122 @@ +package codel.signal.presentation + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.member.presentation.response.UnlockedMemberProfileResponse +import codel.member.presentation.response.FullProfileResponse +import codel.signal.business.SignalService +import codel.signal.presentation.request.HideSignalRequest +import codel.signal.presentation.request.SendSignalRequest +import codel.signal.presentation.response.ReceivedSignalMemberResponse +import codel.signal.presentation.response.SignalMemberResponse +import codel.signal.presentation.response.SignalResponse +import codel.signal.presentation.swagger.SignalControllerSwagger +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v1/signals") +class SignalController( + private val signalService: SignalService, + private val messagingTemplate: SimpMessagingTemplate, +) : SignalControllerSwagger { + @PostMapping + override fun sendSignal( + @LoginMember fromMember: Member, + @RequestBody request: SendSignalRequest + ): ResponseEntity { + val signal = signalService.sendSignal(fromMember, request.toMemberId, request.message) + return ResponseEntity.ok(SignalResponse.fromSend(signal)) + } + + @GetMapping("/received") + override fun getReceiveSignalForMe( + @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity> { + val signals = signalService.getReceivedSignals(me, page, size) + return ResponseEntity.ok(signals.map { ReceivedSignalMemberResponse.fromReceive(it) }); + } + + @GetMapping("/send") + override fun getSendSignalByMe( + @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity> { + val signals = signalService.getSendSignalByMe(me, page, size) + return ResponseEntity.ok(signals.map { SignalMemberResponse.fromSend(it) }) + } + + // Deprecated - 사용하게되면 수정해야함(송신자 , 수신자에 따른 상태 다르게 반환해야하기 때문에) + @GetMapping("/approved") + override fun getAcceptedSignal( + @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> { + val acceptedSignals = signalService.getAcceptedSignals(me, page, size); + return ResponseEntity.ok(acceptedSignals.map { SignalMemberResponse.fromSend(it) }) + } + + @PostMapping("/{id}/approve") + override fun acceptSignal( + @LoginMember me: Member, + @PathVariable id: Long + ): ResponseEntity { + val result = signalService.acceptSignal(me, id) + + // 시그널 발송자(partner)에게 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${result.partner.id}", + result.partnerChatRoomResponse, + ) + + // 시그널 수신자(me)에게 전송 + messagingTemplate.convertAndSend( + "/sub/v1/chatroom/member/${me.id}", + result.approverChatRoomResponse, + ) + return ResponseEntity.ok().build() + } + + @PostMapping("/{id}/reject") + override fun rejectSignal( + @LoginMember me: Member, + @PathVariable id: Long + ): ResponseEntity { + signalService.rejectSignal(me, id) + return ResponseEntity.ok().build() + } + + @GetMapping("/unlocked") + override fun getUnlockedSignal( + @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity> { + val unlockedMemberProfileResponses = signalService.getUnlockedSignal(me, page, size); + return ResponseEntity.ok(unlockedMemberProfileResponses) + } + + @PatchMapping("/{id}/hide") + override fun hideSignal( + @LoginMember me: Member, + @PathVariable id: Long, + ): ResponseEntity { + signalService.hideSignal(me, id); + return ResponseEntity.ok().build() + } + + @PostMapping("/hide") + override fun hideSignals( + @LoginMember me : Member, + @RequestBody hideSignalRequest : HideSignalRequest + ): ResponseEntity { + signalService.hideSignals(me, hideSignalRequest.ids) + return ResponseEntity.ok().build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/presentation/request/HideSignalRequest.kt b/src/main/kotlin/codel/signal/presentation/request/HideSignalRequest.kt new file mode 100644 index 00000000..31414b30 --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/request/HideSignalRequest.kt @@ -0,0 +1,5 @@ +package codel.signal.presentation.request + +data class HideSignalRequest( + val ids : List +) \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/presentation/request/SendSignalRequest.kt b/src/main/kotlin/codel/signal/presentation/request/SendSignalRequest.kt new file mode 100644 index 00000000..018d4475 --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/request/SendSignalRequest.kt @@ -0,0 +1,6 @@ +package codel.signal.presentation.request + +data class SendSignalRequest( + val toMemberId: Long, + val message: String, // 상대방(승인자) 질문에 대한 답변 +) \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/presentation/response/AcceptSignalResult.kt b/src/main/kotlin/codel/signal/presentation/response/AcceptSignalResult.kt new file mode 100644 index 00000000..1572662d --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/response/AcceptSignalResult.kt @@ -0,0 +1,10 @@ +package codel.signal.presentation.response + +import codel.chat.presentation.response.ChatRoomResponse +import codel.member.domain.Member + +data class AcceptSignalResult( + val approverChatRoomResponse: ChatRoomResponse, + val partnerChatRoomResponse: ChatRoomResponse, + val partner: Member +) diff --git a/src/main/kotlin/codel/signal/presentation/response/ReceivedSignalMemberResponse.kt b/src/main/kotlin/codel/signal/presentation/response/ReceivedSignalMemberResponse.kt new file mode 100644 index 00000000..b003aed3 --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/response/ReceivedSignalMemberResponse.kt @@ -0,0 +1,34 @@ +package codel.signal.presentation.response + +import codel.member.presentation.response.FullProfileResponse +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus +import java.time.LocalDateTime + +data class ReceivedSignalMemberResponse( + val signalId: Long, + val member: FullProfileResponse, + val signalMessageBySender : String, + val status: SignalStatus, + val createAt: String +) { + companion object { + fun fromSend(signal: Signal): ReceivedSignalMemberResponse = + ReceivedSignalMemberResponse( + signalId = signal.getIdOrThrow(), + member = FullProfileResponse.createOpen(signal.toMember), // 시그널 단계에서는 Open만 + signalMessageBySender = signal.message, + status = signal.senderStatus, + createAt = signal.createdAt.toString() + ) + + fun fromReceive(signal: Signal): ReceivedSignalMemberResponse = + ReceivedSignalMemberResponse( + signalId = signal.getIdOrThrow(), + member = FullProfileResponse.createOpen(signal.fromMember), // 시그널 단계에서는 Open만 + signalMessageBySender = signal.message, + status = signal.receiverStatus, + createAt = signal.createdAt.toString() + ) + } +} diff --git a/src/main/kotlin/codel/signal/presentation/response/SignalMemberResponse.kt b/src/main/kotlin/codel/signal/presentation/response/SignalMemberResponse.kt new file mode 100644 index 00000000..3366d4de --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/response/SignalMemberResponse.kt @@ -0,0 +1,31 @@ +package codel.signal.presentation.response + +import codel.member.presentation.response.FullProfileResponse +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus +import java.time.LocalDateTime + +data class SignalMemberResponse( + val signalId: Long, + val member: FullProfileResponse, + val status: SignalStatus, + val createAt: String +) { + companion object { + fun fromSend(signal: Signal): SignalMemberResponse = + SignalMemberResponse( + signalId = signal.getIdOrThrow(), + member = FullProfileResponse.createOpen(signal.toMember), // 시그널 단계에서는 Open만 + status = signal.senderStatus, + createAt = signal.createdAt.toString() + ) + + fun fromReceive(signal: Signal): SignalMemberResponse = + SignalMemberResponse( + signalId = signal.getIdOrThrow(), + member = FullProfileResponse.createOpen(signal.fromMember), // 시그널 단계에서는 Open만 + status = signal.receiverStatus, + createAt = signal.createdAt.toString() + ) + } +} diff --git a/src/main/kotlin/codel/signal/presentation/response/SignalResponse.kt b/src/main/kotlin/codel/signal/presentation/response/SignalResponse.kt new file mode 100644 index 00000000..902190ab --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/response/SignalResponse.kt @@ -0,0 +1,32 @@ +package codel.signal.presentation.response + +import codel.signal.domain.Signal +import codel.signal.domain.SignalStatus + +data class SignalResponse( + val id: Long, + val fromMemberId: Long, + val toMemberId: Long, + val status: SignalStatus, + val toMemberFcmToken: String?, +) { + companion object { + fun from(signal: Signal) = SignalResponse( + id = signal.getIdOrThrow(), + fromMemberId = signal.fromMember.getIdOrThrow(), + toMemberId = signal.toMember.getIdOrThrow(), + status = signal.senderStatus, + toMemberFcmToken = signal.toMember.fcmToken + ) + + fun fromSend(signal: Signal): SignalResponse { + return SignalResponse( + id = signal.getIdOrThrow(), + fromMemberId = signal.fromMember.getIdOrThrow(), + toMemberId = signal.toMember.getIdOrThrow(), + status = signal.senderStatus, + toMemberFcmToken = signal.toMember.fcmToken // 푸시는 받는 쪽에게 가야 하므로 유지 + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/signal/presentation/swagger/SignalControllerSwagger.kt b/src/main/kotlin/codel/signal/presentation/swagger/SignalControllerSwagger.kt new file mode 100644 index 00000000..b823f4ab --- /dev/null +++ b/src/main/kotlin/codel/signal/presentation/swagger/SignalControllerSwagger.kt @@ -0,0 +1,201 @@ +package codel.signal.presentation.swagger + +import codel.config.argumentresolver.LoginMember +import codel.member.domain.Member +import codel.member.presentation.response.UnlockedMemberProfileResponse +import codel.signal.presentation.request.HideSignalRequest +import codel.signal.presentation.request.SendSignalRequest +import codel.signal.presentation.response.ReceivedSignalMemberResponse +import codel.signal.presentation.response.SignalMemberResponse +import codel.signal.presentation.response.SignalResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.data.domain.Page +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBody + +@Tag(name = "Signal", description = "시그널(신호) 관련 API") +interface SignalControllerSwagger { + @Operation( + summary = "시그널 보내기", + description = "다른 회원에게 시그널을 보냅니다. 자기 자신에게는 보낼 수 없으며, 최근 상태에 따라 제한이 있을 수 있습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)", + requestBody = SwaggerRequestBody( + required = true, + content = [Content(schema = Schema(implementation = SendSignalRequest::class))] + ) + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "시그널 전송 성공", + content = [Content(schema = Schema(implementation = SignalResponse::class))] + ), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 비즈니스 규칙 위반"), + ApiResponse(responseCode = "404", description = "대상 회원 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun sendSignal( + @Parameter(hidden = true) @LoginMember fromMember: Member, + @RequestBody request: SendSignalRequest + ): ResponseEntity + + @Operation( + summary = "내가 받은 시그널 목록 조회", + description = "내가 받은 시그널(신호) 목록을 페이징하여 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "조회 성공", + content = [Content(schema = Schema(implementation = SignalMemberResponse::class))] + ), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun getReceiveSignalForMe( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> + + @Operation( + summary = "내가 보낸 시그널 목록 조회", + description = "내가 보낸 시그널(신호) 목록을 페이징하여 조회합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "조회 성공", + content = [Content(schema = Schema(implementation = SignalMemberResponse::class))] + ), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun getSendSignalByMe( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> + + @Operation( + summary = "매칭 성공된 시그널 목록 조회", + description = "매칭 성공된 시그널(신호) 목록을 페이징하여 조회합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "조회 성공", + content = [Content(schema = Schema(implementation = SignalMemberResponse::class))] + ), + ApiResponse(responseCode = "401", description = "인증 실패"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun getAcceptedSignal( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> + + @Operation( + summary = "시그널 수락", + description = "내가 받은 시그널을 수락합니다. 이미 처리된 시그널은 수락할 수 없습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "수락 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 이미 처리된 시그널"), + ApiResponse(responseCode = "404", description = "시그널 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun acceptSignal( + @Parameter(hidden = true) @LoginMember me: Member, + @PathVariable id: Long + ): ResponseEntity + + @Operation( + summary = "시그널 거절", + description = "내가 받은 시그널을 거절합니다. 이미 처리된 시그널은 거절할 수 없습니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "거절 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 이미 처리된 시그널"), + ApiResponse(responseCode = "404", description = "시그널 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun rejectSignal( + @Parameter(hidden = true) @LoginMember me: Member, + @PathVariable id: Long + ): ResponseEntity + + + @Operation( + summary = "시그널 코드해제 조회", + description = "코드 해제된 시그널을 조회합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "거절 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 이미 처리된 시그널"), + ApiResponse(responseCode = "404", description = "시그널 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun getUnlockedSignal( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity> + + @Operation( + summary = "시그널 숨김", + description = "나와 관련된 시그널을 숨김 처리합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "숨김 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 이미 처리된 시그널"), + ApiResponse(responseCode = "404", description = "시그널 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun hideSignal( + @Parameter(hidden = true) @LoginMember me: Member, + @PathVariable id: Long + ): ResponseEntity + + @Operation( + summary = "시그널 리스트 숨김 처리", + description = "나와 관련된 시그널을 리스트형식으로 숨김 처리합니다. (※ Authorization 헤더에 JWT를 포함시켜야 합니다.)" + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "숨김 성공"), + ApiResponse(responseCode = "400", description = "잘못된 요청 또는 이미 처리된 시그널"), + ApiResponse(responseCode = "404", description = "시그널 없음"), + ApiResponse(responseCode = "500", description = "서버 오류") + ] + ) + fun hideSignals( + @Parameter(hidden = true) @LoginMember me: Member, + @RequestBody hideSignalRequest: HideSignalRequest + ): ResponseEntity +} diff --git a/src/main/kotlin/codel/verification/business/VerificationImageService.kt b/src/main/kotlin/codel/verification/business/VerificationImageService.kt new file mode 100644 index 00000000..07ece493 --- /dev/null +++ b/src/main/kotlin/codel/verification/business/VerificationImageService.kt @@ -0,0 +1,33 @@ +package codel.verification.business + +import codel.config.Loggable +import codel.verification.infrastructure.StandardVerificationImageJpaRepository +import codel.verification.presentation.response.StandardVerificationImageResponse +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + + +@Service +@Transactional(readOnly = true) +class VerificationImageService( + private val standardVerificationImageRepository: StandardVerificationImageJpaRepository +) : Loggable{ + + /** + * 활성화된 표준 인증 이미지 중 랜덤으로 하나 조회 (사용자용) + * isActive = true인 이미지 중 랜덤 선택 + */ + fun getRandomStandardImage(): StandardVerificationImageResponse { + log.info { "활성화된 표준 인증 이미지 랜덤 조회" } + + val standardImages = standardVerificationImageRepository.findAllByIsActiveTrue() + + require(standardImages.isNotEmpty()) { "활성화된 표준 인증 이미지가 없습니다." } + + val randomImage = standardImages.random() + + log.info { "랜덤 선택된 표준 이미지 ID: ${randomImage.id}" } + + return StandardVerificationImageResponse.from(randomImage) + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/verification/domain/StandardVerificationImage.kt b/src/main/kotlin/codel/verification/domain/StandardVerificationImage.kt new file mode 100644 index 00000000..699ffc6d --- /dev/null +++ b/src/main/kotlin/codel/verification/domain/StandardVerificationImage.kt @@ -0,0 +1,39 @@ +package codel.verification.domain + +import codel.common.domain.BaseTimeEntity +import jakarta.persistence.* + +/** + * 표준 인증 이미지 (관리자가 등록한 가이드 포즈 이미지) + * + * 사용자는 이 표준 이미지를 보고 동일한 자세로 인증 이미지를 촬영합니다. + */ +@Entity +@Table(name = "standard_verification_images") +class StandardVerificationImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + /** + * 표준 이미지 S3 URL + * 예: https://s3.../standard_verification_images/pose1.jpg + */ + @Column(nullable = false, length = 1000) + var imageUrl: String, + + /** + * 포즈 설명 + * 예: "정면을 보고 양손을 귀 옆에 올려주세요" + */ + @Column(nullable = false) + var description: String, + + /** + * 활성화 여부 + * false인 경우 사용자에게 노출되지 않음 + */ + @Column(nullable = false) + var isActive: Boolean = true + +) : BaseTimeEntity() \ No newline at end of file diff --git a/src/main/kotlin/codel/verification/domain/VerificationImage.kt b/src/main/kotlin/codel/verification/domain/VerificationImage.kt new file mode 100644 index 00000000..49e43917 --- /dev/null +++ b/src/main/kotlin/codel/verification/domain/VerificationImage.kt @@ -0,0 +1,76 @@ +package codel.verification.domain + +import codel.common.domain.BaseTimeEntity +import codel.member.domain.Member +import jakarta.persistence.* +import java.time.LocalDateTime + +/** + * 사용자 인증 이미지 + * + * 사용자가 표준 이미지를 보고 동일한 자세로 촬영한 본인 인증 사진 + * - 재제출 가능 (이력 관리) + * - 소프트 딜리트 지원 + */ +@Entity +@Table( + name = "verification_images", + indexes = [ + Index(name = "idx_member_id", columnList = "member_id"), + Index(name = "idx_standard_image_id", columnList = "standard_verification_image_id"), + Index(name = "idx_deleted_at", columnList = "deleted_at") + ] +) +class VerificationImage( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null, + + /** + * 회원 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + + /** + * 참조한 표준 인증 이미지 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "standard_verification_image_id", nullable = false) + var standardVerificationImage: StandardVerificationImage, + + /** + * 사용자가 촬영한 인증 이미지 S3 URL + * 경로: /verification_images/{memberId}/{uuid}.jpg + */ + @Column(nullable = false, length = 1000) + var userImageUrl: String, + + /** + * 소프트 딜리트 시간 + * null이 아니면 삭제된 것으로 간주 + */ + var deletedAt: LocalDateTime? = null + +) : BaseTimeEntity() { + + /** + * 삭제 여부 확인 + */ + fun isDeleted(): Boolean = deletedAt != null + + /** + * 소프트 딜리트 처리 + */ + fun softDelete() { + deletedAt = LocalDateTime.now() + } + + /** + * 소프트 딜리트 복구 + */ + fun restore() { + deletedAt = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/codel/verification/infrastructure/StandardVerificationImageJpaRepository.kt b/src/main/kotlin/codel/verification/infrastructure/StandardVerificationImageJpaRepository.kt new file mode 100644 index 00000000..55670ca3 --- /dev/null +++ b/src/main/kotlin/codel/verification/infrastructure/StandardVerificationImageJpaRepository.kt @@ -0,0 +1,15 @@ +package codel.verification.infrastructure + +import codel.verification.domain.StandardVerificationImage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface StandardVerificationImageJpaRepository : JpaRepository { + + /** + * 활성화된 표준 이미지만 조회 (사용자용) + * 랜덤으로 하나 선택하기 위해 모두 조회 + */ + fun findAllByIsActiveTrue(): List +} \ No newline at end of file diff --git a/src/main/kotlin/codel/verification/infrastructure/VerificationImageJpaRepository.kt b/src/main/kotlin/codel/verification/infrastructure/VerificationImageJpaRepository.kt new file mode 100644 index 00000000..e2b9c9a6 --- /dev/null +++ b/src/main/kotlin/codel/verification/infrastructure/VerificationImageJpaRepository.kt @@ -0,0 +1,35 @@ +package codel.verification.infrastructure + +import codel.member.domain.Member +import codel.verification.domain.VerificationImage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface VerificationImageJpaRepository : JpaRepository { + + /** + * 회원의 최신 인증 이미지 조회 (삭제되지 않은 것만) + * 재제출이 가능하므로 가장 최근 것만 조회 + */ + fun findFirstByMemberAndDeletedAtIsNullOrderByCreatedAtDesc(member: Member): VerificationImage? + + /** + * 회원의 최신 인증 이미지 조회 with 표준 이미지 (관리자용) + */ + @Query("SELECT v FROM VerificationImage v JOIN FETCH v.standardVerificationImage WHERE v.member = :member AND v.deletedAt IS NULL ORDER BY v.createdAt DESC") + fun findFirstByMemberWithStandardImage(member: Member): VerificationImage? + + /** + * 회원의 모든 인증 이미지 이력 조회 (관리자용) + * 삭제된 것 포함, 최신순 + */ + fun findAllByMemberOrderByCreatedAtDesc(member: Member): List + + /** + * 회원의 유효한 인증 이미지 이력 조회 + * 삭제되지 않은 것만, 최신순 + */ + fun findAllByMemberAndDeletedAtIsNullOrderByCreatedAtDesc(member: Member): List +} diff --git a/src/main/kotlin/codel/verification/presentation/VerificationImageController.kt b/src/main/kotlin/codel/verification/presentation/VerificationImageController.kt new file mode 100644 index 00000000..ac43a6c7 --- /dev/null +++ b/src/main/kotlin/codel/verification/presentation/VerificationImageController.kt @@ -0,0 +1,22 @@ +package codel.verification.presentation + +import codel.verification.business.VerificationImageService +import codel.verification.presentation.response.StandardVerificationImageResponse +import codel.verification.presentation.swagger.VerificationImageControllerSwagger +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 + +@RestController +@RequestMapping("/v1/verification") +class VerificationImageController( + private val verificationImageService: VerificationImageService +) : VerificationImageControllerSwagger { + + @GetMapping("/standard-image") + override fun getRandomStandardImage(): ResponseEntity { + val standardImage = verificationImageService.getRandomStandardImage() + return ResponseEntity.ok(standardImage) + } +} diff --git a/src/main/kotlin/codel/verification/presentation/response/StandardVerificationImageResponse.kt b/src/main/kotlin/codel/verification/presentation/response/StandardVerificationImageResponse.kt new file mode 100644 index 00000000..aef1c40a --- /dev/null +++ b/src/main/kotlin/codel/verification/presentation/response/StandardVerificationImageResponse.kt @@ -0,0 +1,22 @@ +package codel.verification.presentation.response + +import codel.verification.domain.StandardVerificationImage + +/** + * 표준 인증 이미지 응답 DTO + */ +data class StandardVerificationImageResponse( + val id: Long, + val imageUrl: String, + val description: String +) { + companion object { + fun from(standardImage: StandardVerificationImage): StandardVerificationImageResponse { + return StandardVerificationImageResponse( + id = standardImage.id!!, + imageUrl = standardImage.imageUrl, + description = standardImage.description + ) + } + } +} diff --git a/src/main/kotlin/codel/verification/presentation/response/VerificationImageResponse.kt b/src/main/kotlin/codel/verification/presentation/response/VerificationImageResponse.kt new file mode 100644 index 00000000..c5a4a366 --- /dev/null +++ b/src/main/kotlin/codel/verification/presentation/response/VerificationImageResponse.kt @@ -0,0 +1,36 @@ +package codel.verification.presentation.response + +import codel.member.domain.MemberStatus +import codel.verification.domain.VerificationImage +import java.time.LocalDateTime + +/** + * 인증 이미지 제출 응답 DTO + */ +data class VerificationImageResponse( + val memberId: Long, + val memberStatus: MemberStatus, + val verificationImage: VerificationImageDetail +) { + data class VerificationImageDetail( + val id: Long, + val standardImageId: Long, + val userImageUrl: String, + val createdAt: LocalDateTime + ) + + companion object { + fun from(memberId: Long, memberStatus: MemberStatus, verificationImage: VerificationImage): VerificationImageResponse { + return VerificationImageResponse( + memberId = memberId, + memberStatus = memberStatus, + verificationImage = VerificationImageDetail( + id = verificationImage.id!!, + standardImageId = verificationImage.standardVerificationImage.id!!, + userImageUrl = verificationImage.userImageUrl, + createdAt = verificationImage.createdAt + ) + ) + } + } +} diff --git a/src/main/kotlin/codel/verification/presentation/swagger/VerificationImageControllerSwagger.kt b/src/main/kotlin/codel/verification/presentation/swagger/VerificationImageControllerSwagger.kt new file mode 100644 index 00000000..4851e152 --- /dev/null +++ b/src/main/kotlin/codel/verification/presentation/swagger/VerificationImageControllerSwagger.kt @@ -0,0 +1,58 @@ +package codel.verification.presentation.swagger + +import codel.verification.presentation.response.StandardVerificationImageResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity + +@Tag(name = "Verification Image", description = "인증 이미지 관련 API") +interface VerificationImageControllerSwagger { + + @Operation( + summary = "표준 인증 이미지 랜덤 조회", + description = """ + 회원 인증을 위한 표준 포즈 이미지를 랜덤으로 하나 조회합니다. + + 사용자는 표준 이미지를 보고 동일한 자세로 본인 사진을 촬영하여 제출해야 합니다. + + **사용 시점:** + - 회원가입 플로우에서 VERIFICATION_IMAGE 상태일 때 + - 심사 거절 후 재제출 시 + + **반환 데이터:** + - 활성화된(isActive=true) 표준 이미지 중 랜덤으로 하나 반환 + + **응답 예시:** + ```json + { + "id": 1, + "imageUrl": "https://s3.../standard_verification_images/pose1.jpg", + "description": "정면을 보고 양손을 귀 옆에 올려주세요" + } + ``` + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "표준 인증 이미지 조회 성공", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = StandardVerificationImageResponse::class) + )] + ), + ApiResponse( + responseCode = "500", + description = "서버 내부 오류 - 활성화된 표준 이미지가 없거나 조회 실패", + content = [Content()] + ) + ] + ) + fun getRandomStandardImage(): ResponseEntity +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index b1f198a0..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=reunion diff --git a/src/main/resources/db/migration/V10__add_if_question_category.sql b/src/main/resources/db/migration/V10__add_if_question_category.sql new file mode 100644 index 00000000..639d56ab --- /dev/null +++ b/src/main/resources/db/migration/V10__add_if_question_category.sql @@ -0,0 +1,7 @@ +-- V10__add_if_question_category.sql +-- Add 'IF' category to question table's category enum + +ALTER TABLE `question` +MODIFY COLUMN `category` +ENUM('BALANCE_ONE','CURRENT_ME','DATE','FAVORITE','IF','MEMORY','VALUES','WANT_TALK') +NOT NULL; diff --git a/src/main/resources/db/migration/V11__create_rejection_histories_table.sql b/src/main/resources/db/migration/V11__create_rejection_histories_table.sql new file mode 100644 index 00000000..d12f5aa7 --- /dev/null +++ b/src/main/resources/db/migration/V11__create_rejection_histories_table.sql @@ -0,0 +1,50 @@ +-- ================================================ +-- V11: 거절 이력 관리 시스템 +-- ================================================ +-- 작성일: 2025-01-15 +-- 설명: 프로필 심사 거절 이력을 차수별로 관리하기 위한 테이블 생성 +-- S3 이미지 URL을 보존하여 과거 거절 내역 조회 가능 +-- ================================================ + +-- 1. rejection_histories 테이블 생성 +CREATE TABLE rejection_histories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '거절 이력 ID', + member_id BIGINT NOT NULL COMMENT '회원 ID', + rejection_round INT NOT NULL COMMENT '거절 차수 (1, 2, 3...)', + image_type VARCHAR(50) NOT NULL COMMENT '거절된 이미지 타입 (FACE_IMAGE, CODE_IMAGE)', + image_id BIGINT NOT NULL COMMENT '거절된 이미지의 실제 ID', + image_url VARCHAR(500) NOT NULL COMMENT '거절 당시 이미지 URL (S3에 보존)', + image_order INT NOT NULL COMMENT '이미지 순서 (1부터 시작)', + reason VARCHAR(1000) NOT NULL COMMENT '거절 사유', + rejected_at DATETIME NOT NULL COMMENT '거절 시각', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + + -- 외래키 + CONSTRAINT fk_rejection_history_member + FOREIGN KEY (member_id) REFERENCES member(id) + ON DELETE CASCADE, + + -- 인덱스 + INDEX idx_member_id (member_id), + INDEX idx_member_rejection_round (member_id, rejection_round), + INDEX idx_created_at (created_at), + INDEX idx_rejected_at (rejected_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='프로필 심사 거절 이력'; + +-- ================================================ +-- 설명: +-- +-- 이 테이블은 회원의 프로필 거절 이력을 관리합니다. +-- +-- 주요 특징: +-- 1. 거절 차수(rejection_round)로 여러 번의 거절을 구분 +-- 2. S3 이미지 URL을 보존하여 과거 이미지 확인 가능 +-- 3. 이미지 타입(얼굴/코드), 순서, 거절 사유 모두 기록 +-- 4. 회원 삭제 시 이력도 함께 삭제 (CASCADE) +-- +-- 사용 예시: +-- - 회원 A가 1차 거절: rejection_round = 1 +-- - 이미지 재제출 후 2차 거절: rejection_round = 2 +-- - 관리자가 과거 거절 이력 조회 시 모든 차수의 이력 확인 가능 +-- ================================================ diff --git a/src/main/resources/db/migration/V12__add_verification_image_to_member_status.sql b/src/main/resources/db/migration/V12__add_verification_image_to_member_status.sql new file mode 100644 index 00000000..1e8acf3a --- /dev/null +++ b/src/main/resources/db/migration/V12__add_verification_image_to_member_status.sql @@ -0,0 +1,23 @@ +-- V12__add_verification_image_to_member_status.sql +-- Add VERIFICATION_IMAGE status to member_status enum + +-- MySQL enum 타입에 새로운 값 추가 +ALTER TABLE `member` + MODIFY COLUMN `member_status` ENUM( + 'ADMIN', + 'DONE', + 'ESSENTIAL_COMPLETED', + 'HIDDEN_COMPLETED', + 'PENDING', + 'PERSONALITY_COMPLETED', + 'PHONE_VERIFIED', + 'REJECT', + 'SIGNUP', + 'VERIFICATION_IMAGE', -- 신규 추가 + 'WITHDRAWN' + ) DEFAULT NULL; + +-- 설명: +-- VERIFICATION_IMAGE 상태는 HIDDEN_COMPLETED와 PENDING 사이의 단계입니다. +-- 회원이 히든 프로필(얼굴 이미지)을 완료한 후, 인증 이미지를 제출하는 단계를 의미합니다. +-- 회원가입 플로우: HIDDEN_COMPLETED → VERIFICATION_IMAGE → PENDING → DONE diff --git a/src/main/resources/db/migration/V13__create_standard_verification_images_table.sql b/src/main/resources/db/migration/V13__create_standard_verification_images_table.sql new file mode 100644 index 00000000..0413e056 --- /dev/null +++ b/src/main/resources/db/migration/V13__create_standard_verification_images_table.sql @@ -0,0 +1,18 @@ +-- V13__create_standard_verification_images_table.sql +-- Create standard_verification_images table for admin-managed pose guide images + +CREATE TABLE `standard_verification_images` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `image_url` varchar(1000) NOT NULL COMMENT '표준 이미지 S3 URL', + `description` varchar(255) NOT NULL COMMENT '포즈 설명 (예: 정면을 보고 양손을 귀 옆에 올려주세요)', + `is_active` bit(1) NOT NULL DEFAULT 1 COMMENT '활성화 여부 (비활성화된 이미지는 사용자에게 노출 안됨)', + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +COMMENT='관리자가 등록한 표준 인증 이미지 (사용자 인증용 가이드 포즈)'; + +-- 설명: +-- 표준 이미지는 관리자 페이지에서 등록/수정/삭제 +-- 사용자는 활성화된(is_active=1) 표준 이미지 중 랜덤으로 하나를 조회 diff --git a/src/main/resources/db/migration/V14__create_verification_images_table.sql b/src/main/resources/db/migration/V14__create_verification_images_table.sql new file mode 100644 index 00000000..9bd20117 --- /dev/null +++ b/src/main/resources/db/migration/V14__create_verification_images_table.sql @@ -0,0 +1,28 @@ +-- V14__create_verification_images_table.sql +-- Create verification_images table for user-submitted verification images + +CREATE TABLE `verification_images` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `member_id` bigint NOT NULL, + `standard_verification_image_id` bigint NOT NULL, + `user_image_url` varchar(1000) NOT NULL COMMENT '사용자가 촬영한 인증 이미지 S3 URL (/verification_images/)', + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `deleted_at` datetime(6) DEFAULT NULL COMMENT '소프트 딜리트 (회원 탈퇴 시)', + PRIMARY KEY (`id`), + KEY `idx_member_id` (`member_id`), + KEY `idx_standard_image_id` (`standard_verification_image_id`), + KEY `idx_deleted_at` (`deleted_at`), + CONSTRAINT `fk_verification_image_member` + FOREIGN KEY (`member_id`) REFERENCES `member` (`id`), + CONSTRAINT `fk_verification_image_standard` + FOREIGN KEY (`standard_verification_image_id`) + REFERENCES `standard_verification_images` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci +COMMENT='사용자가 제출한 인증 이미지 (재제출 가능, 이력 관리)'; + +-- 설명: +-- 재제출 가능하므로 UNIQUE 제약 없음 +-- 한 회원이 여러 번 제출 가능 (이력 관리) +-- 최신 이미지: ORDER BY created_at DESC LIMIT 1 +-- 소프트 딜리트: deleted_at NOT NULL인 레코드는 삭제된 것으로 간주 diff --git a/src/main/resources/db/migration/V15__remove_verification_image_from_member_status.sql b/src/main/resources/db/migration/V15__remove_verification_image_from_member_status.sql new file mode 100644 index 00000000..d1411076 --- /dev/null +++ b/src/main/resources/db/migration/V15__remove_verification_image_from_member_status.sql @@ -0,0 +1,22 @@ +-- V15__remove_verification_image_from_member_status.sql +-- Remove VERIFICATION_IMAGE status from member_status enum + +-- MySQL enum 타입에서 VERIFICATION_IMAGE 제거 +ALTER TABLE `member` + MODIFY COLUMN `member_status` ENUM( + 'ADMIN', + 'DONE', + 'ESSENTIAL_COMPLETED', + 'HIDDEN_COMPLETED', + 'PENDING', + 'PERSONALITY_COMPLETED', + 'PHONE_VERIFIED', + 'REJECT', + 'SIGNUP', + 'WITHDRAWN' + ) DEFAULT NULL; + +-- 설명: +-- VERIFICATION_IMAGE 상태를 제거하고 회원가입 플로우를 단순화합니다. +-- 변경된 회원가입 플로우: HIDDEN_COMPLETED → (인증 이미지 제출) → PENDING → DONE +-- 인증 이미지 제출은 HIDDEN_COMPLETED 상태에서 이루어지며, 제출 후 바로 PENDING 상태로 전환됩니다. diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..13f2c418 --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,283 @@ +-- V1__init_schema.sql +-- Flyway init schema migration (order fixed) +SET FOREIGN_KEY_CHECKS = 0; + +-- ========================= +-- DROP TABLES (children → parents) +-- ========================= +DROP TABLE IF EXISTS `chat_room_question`; +DROP TABLE IF EXISTS `code_unlock_request`; +DROP TABLE IF EXISTS `chat_room_member`; +DROP TABLE IF EXISTS `chat`; +DROP TABLE IF EXISTS `block_member_relation`; +DROP TABLE IF EXISTS `member_signal`; +DROP TABLE IF EXISTS `report`; +DROP TABLE IF EXISTS `face_images`; +DROP TABLE IF EXISTS `code_images`; +DROP TABLE IF EXISTS `profiles`; +DROP TABLE IF EXISTS `reject_reason`; +DROP TABLE IF EXISTS `question`; +DROP TABLE IF EXISTS `chat_room`; +DROP TABLE IF EXISTS `member`; + +-- ========================= +-- CREATE TABLES (parents → children) +-- ========================= + +-- member (root) +CREATE TABLE `member` ( + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `fcm_token` varchar(255) DEFAULT NULL, + `oauth_id` varchar(255) DEFAULT NULL, + `reject_reason` varchar(255) DEFAULT NULL, + `member_status` enum('ADMIN','DONE','ESSENTIAL_COMPLETED','HIDDEN_COMPLETED','PENDING','PERSONALITY_COMPLETED','PHONE_VERIFIED','REJECT','SIGNUP','WITHDRAWN') DEFAULT NULL, + `oauth_type` enum('ADMIN','APPLE','GOOGLE','KAKAO') DEFAULT NULL, + `withdrawn_reason` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKpogkt256oewuximsknodfn6da` (`oauth_type`,`oauth_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- question (root) +CREATE TABLE `question` ( + `is_active` bit(1) NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `content` varchar(500) NOT NULL, + `description` varchar(1000) DEFAULT NULL, + `category` enum('BALANCE_ONE','CURRENT_ME','DATE','FAVORITE','MEMORY','VALUES','WANT_TALK') NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- profiles (FK → member, question) +CREATE TABLE `profiles` ( + `birth_date` date DEFAULT NULL, + `essential_completed` bit(1) NOT NULL, + `height` int DEFAULT NULL, + `hidden_completed` bit(1) NOT NULL, + `personality_completed` bit(1) NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `essential_completed_at` datetime(6) DEFAULT NULL, + `hidden_completed_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `member_id` bigint DEFAULT NULL, + `personality_completed_at` datetime(6) DEFAULT NULL, + `representative_question_id` bigint DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `code_image` varchar(1000) DEFAULT NULL, + `face_image` varchar(1000) DEFAULT NULL, + `affection_style` varchar(255) DEFAULT NULL, + `alcohol` varchar(255) DEFAULT NULL, + `big_city` varchar(255) DEFAULT NULL, + `body_type` varchar(255) DEFAULT NULL, + `code_name` varchar(255) DEFAULT NULL, + `conflict_resolution_style` varchar(255) DEFAULT NULL, + `contact_style` varchar(255) DEFAULT NULL, + `date_style` varchar(255) DEFAULT NULL, + `hair_length` varchar(255) DEFAULT NULL, + `interests` varchar(255) DEFAULT NULL, + `introduce` varchar(255) DEFAULT NULL, + `job` varchar(255) DEFAULT NULL, + `love_language` varchar(255) DEFAULT NULL, + `mbti` varchar(255) DEFAULT NULL, + `personalities` varchar(255) DEFAULT NULL, + `relationship_values` varchar(255) DEFAULT NULL, + `representative_answer` varchar(255) DEFAULT NULL, + `small_city` varchar(255) DEFAULT NULL, + `smoke` varchar(255) DEFAULT NULL, + `style` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKa26u3c3eoglisoov0p1k1841f` (`member_id`), + KEY `FK5o19tomscbkmbixg93uj3ieih` (`representative_question_id`), + CONSTRAINT `FK3je4xlea0lern2dsaq2ofgyfd` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`), + CONSTRAINT `FK5o19tomscbkmbixg93uj3ieih` FOREIGN KEY (`representative_question_id`) REFERENCES `question` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- images (FK → profiles) +CREATE TABLE `code_images` ( + `is_approved` bit(1) NOT NULL, + `orders` int NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `profile_id` bigint NOT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `url` varchar(500) NOT NULL, + `rejection_reason` varchar(1000) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FKs569e60o4xe25dpxb1hmf584d` (`profile_id`), + CONSTRAINT `FKs569e60o4xe25dpxb1hmf584d` FOREIGN KEY (`profile_id`) REFERENCES `profiles` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `face_images` ( + `is_approved` bit(1) NOT NULL, + `orders` int NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `profile_id` bigint NOT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `url` varchar(500) NOT NULL, + `rejection_reason` varchar(1000) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FKcyqplxa5oyjgvk0tghd0ilme2` (`profile_id`), + CONSTRAINT `FKcyqplxa5oyjgvk0tghd0ilme2` FOREIGN KEY (`profile_id`) REFERENCES `profiles` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- chat_room (create WITHOUT recent_chat_id FK first to avoid cycle) +CREATE TABLE `chat_room` ( + `is_unlocked` bit(1) NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `recent_chat_id` bigint DEFAULT NULL, + `unlocked_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `status` enum('DISABLED','LOCKED','UNLOCKED') DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK1b9vcrrg9sp0nkfygcgebv44e` (`recent_chat_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- chat_room_member (create WITHOUT chat_id FK first to avoid cycle) +CREATE TABLE `chat_room_member` ( + `chat_id` bigint DEFAULT NULL, + `chat_room_id` bigint NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `left_at` datetime(6) DEFAULT NULL, + `member_id` bigint NOT NULL, + `member_status` enum('ACTIVE','LEFT') DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKsseano88a8cv0ne1qxsiv1g4v` (`chat_room_id`,`member_id`), + UNIQUE KEY `UKc3bwd8ohk6yni9mjeryembv4g` (`chat_id`), + KEY `FKq64atn9y4cyjpp4qcrllxi3o5` (`member_id`), + KEY `FK_chat_room_member_room` (`chat_room_id`), + CONSTRAINT `FK_chat_room_member_room` FOREIGN KEY (`chat_room_id`) REFERENCES `chat_room` (`id`), + CONSTRAINT `FKq64atn9y4cyjpp4qcrllxi3o5` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- chat (FK → chat_room OK; from_chat_room_member_id will be added later) +CREATE TABLE `chat` ( + `chat_room_id` bigint NOT NULL, + `from_chat_room_member_id` bigint DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `sent_at` datetime(6) DEFAULT NULL, + `message` varchar(255) DEFAULT NULL, + `chat_content_type` enum('CLOSE_CONVERSATION','MATCHED','ONBOARDING','QUESTION','TEXT','TIME','UNLOCKED','UNLOCKED_APPROVED','UNLOCKED_REJECTED','UNLOCKED_REQUEST') DEFAULT NULL, + `sender_type` enum('MY','PARTNER','SYSTEM','USER') DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FK44b6elhh512d2722l09i6qdku` (`chat_room_id`), + KEY `FK9mryo76qkolxuojwwbtj9c1fx` (`from_chat_room_member_id`), + CONSTRAINT `FK44b6elhh512d2722l09i6qdku` FOREIGN KEY (`chat_room_id`) REFERENCES `chat_room` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- other dependents +CREATE TABLE `block_member_relation` ( + `blocked_member_id` bigint DEFAULT NULL, + `blocker_member_id` bigint DEFAULT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) DEFAULT NULL, + `status` enum('BLOCKED','UNBLOCKED') DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FKdsr0m0wy7ihip22ij1ckivgpn` (`blocked_member_id`), + KEY `FKry2oe0ajf7pyv7k8495u6tcs6` (`blocker_member_id`), + CONSTRAINT `FKdsr0m0wy7ihip22ij1ckivgpn` FOREIGN KEY (`blocked_member_id`) REFERENCES `member` (`id`), + CONSTRAINT `FKry2oe0ajf7pyv7k8495u6tcs6` FOREIGN KEY (`blocker_member_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `member_signal` ( + `created_at` datetime(6) DEFAULT NULL, + `from_member_id` bigint DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `to_member_id` bigint DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `message` varchar(255) DEFAULT NULL, + `receiver_status` enum('APPROVED','NONE','PENDING','PENDING_HIDDEN','REJECTED') DEFAULT NULL, + `sender_status` enum('APPROVED','NONE','PENDING','PENDING_HIDDEN','REJECTED') DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FKlobswiesdohss9tutwg3pqb7v` (`from_member_id`), + KEY `FKif6qksw91ei4qedxui44mm9yd` (`to_member_id`), + CONSTRAINT `FKif6qksw91ei4qedxui44mm9yd` FOREIGN KEY (`to_member_id`) REFERENCES `member` (`id`), + CONSTRAINT `FKlobswiesdohss9tutwg3pqb7v` FOREIGN KEY (`from_member_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `reject_reason` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `member_id` bigint DEFAULT NULL, + `reason` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UKkbr846purkbbx7lmb3f7djq8u` (`member_id`), + CONSTRAINT `FKdv8bjogqlopjgj78fo2wa1ed9` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `report` ( + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `reported_id` bigint DEFAULT NULL, + `reporter_id` bigint DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `reason` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FK6ovdlwgf174uw16m9cynvbgal` (`reported_id`), + KEY `FK1uivt2jamt7slp3banldgnsef` (`reporter_id`), + CONSTRAINT `FK1uivt2jamt7slp3banldgnsef` FOREIGN KEY (`reporter_id`) REFERENCES `member` (`id`), + CONSTRAINT `FK6ovdlwgf174uw16m9cynvbgal` FOREIGN KEY (`reported_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `code_unlock_request` ( + `chat_room_id` bigint NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `processed_at` datetime(6) DEFAULT NULL, + `processed_by_id` bigint DEFAULT NULL, + `requested_at` datetime(6) DEFAULT NULL, + `requester_id` bigint NOT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `status` enum('APPROVED','PENDING','REJECTED') DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FKmidqbqlkm89oeydsids7g8xvx` (`chat_room_id`), + KEY `FKasx5j9682qplkvfynoiol0uwm` (`processed_by_id`), + KEY `FKew1slajceqoc7uljtbd7spir9` (`requester_id`), + CONSTRAINT `FKasx5j9682qplkvfynoiol0uwm` FOREIGN KEY (`processed_by_id`) REFERENCES `member` (`id`), + CONSTRAINT `FKew1slajceqoc7uljtbd7spir9` FOREIGN KEY (`requester_id`) REFERENCES `member` (`id`), + CONSTRAINT `FKmidqbqlkm89oeydsids7g8xvx` FOREIGN KEY (`chat_room_id`) REFERENCES `chat_room` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `chat_room_question` ( + `is_used` bit(1) NOT NULL, + `chat_room_id` bigint NOT NULL, + `created_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `question_id` bigint NOT NULL, + `requested_by_member_id` bigint DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `used_at` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `UK1mdkxhjqch6uccxt1iegs4ej5` (`chat_room_id`,`question_id`), + KEY `FK6uvsi17h1sv35piwgc72nq4sx` (`question_id`), + KEY `FKrxxhqhqldakqf0kljq79f0mc3` (`requested_by_member_id`), + CONSTRAINT `FK6uvsi17h1sv35piwgc72nq4sx` FOREIGN KEY (`question_id`) REFERENCES `question` (`id`), + CONSTRAINT `FKermg781g6u4fk2q3p6bhiq86b` FOREIGN KEY (`chat_room_id`) REFERENCES `chat_room` (`id`), + CONSTRAINT `FKrxxhqhqldakqf0kljq79f0mc3` FOREIGN KEY (`requested_by_member_id`) REFERENCES `member` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ========================= +-- ADD CYCLIC FOREIGN KEYS AFTER ALL TABLES EXIST +-- ========================= + +-- chat.from_chat_room_member_id → chat_room_member.id +ALTER TABLE `chat` + ADD CONSTRAINT `FK9mryo76qkolxuojwwbtj9c1fx` + FOREIGN KEY (`from_chat_room_member_id`) REFERENCES `chat_room_member` (`id`); + +-- chat_room_member.chat_id → chat.id +ALTER TABLE `chat_room_member` + ADD CONSTRAINT `FKb9o8lisg7q5wiv978eing6088` + FOREIGN KEY (`chat_id`) REFERENCES `chat` (`id`); + +-- chat_room.recent_chat_id → chat.id +ALTER TABLE `chat_room` + ADD CONSTRAINT `FK44nqbivue0gtsjdpgt7y0imcj` + FOREIGN KEY (`recent_chat_id`) REFERENCES `chat` (`id`); + +SET FOREIGN_KEY_CHECKS = 1; \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__create_recommendation_tables.sql b/src/main/resources/db/migration/V2__create_recommendation_tables.sql new file mode 100644 index 00000000..c55c3e25 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_recommendation_tables.sql @@ -0,0 +1,29 @@ +-- =========================================================== +-- recommendation_history 테이블 생성 +-- =========================================================== +CREATE TABLE IF NOT EXISTS `recommendation_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `recommended_user_id` BIGINT NOT NULL, + `recommended_at` DATETIME(6) NOT NULL, + `recommendation_type` ENUM('DAILY_CODE_MATCHING', 'CODE_TIME') NOT NULL, + `recommendation_time_slot` VARCHAR(50) NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + PRIMARY KEY (`id`), + + CONSTRAINT `fk_recommendation_history_user` + FOREIGN KEY (`user_id`) REFERENCES `member`(`id`), + CONSTRAINT `fk_recommendation_history_recommended_user` + FOREIGN KEY (`recommended_user_id`) REFERENCES `member`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- 인덱스 추가 +CREATE INDEX `idx_user_recommended_at` + ON `recommendation_history` (`user_id`, `recommended_user_id`, `recommended_at`); + +CREATE INDEX `idx_user_recommended_at_only` + ON `recommendation_history` (`user_id`, `recommended_at`); + +CREATE INDEX `idx_recommended_at` + ON `recommendation_history` (`recommended_at`); \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__create_region_mappings.sql b/src/main/resources/db/migration/V3__create_region_mappings.sql new file mode 100644 index 00000000..e585ffdf --- /dev/null +++ b/src/main/resources/db/migration/V3__create_region_mappings.sql @@ -0,0 +1,101 @@ +-- 지역 매핑 테이블 생성 +CREATE TABLE IF NOT EXISTS region_mappings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + main_region VARCHAR(20) NOT NULL, + adjacent_region VARCHAR(20) NOT NULL, + priority_order INT NOT NULL, + + -- 인덱스 생성 + INDEX idx_main_region_priority (main_region, priority_order), + + -- 유니크 제약 (중복 매핑 방지) + UNIQUE KEY uk_region_mapping (main_region, adjacent_region) +); + +-- 초기 지역 매핑 데이터 삽입 (중복 방지) +INSERT IGNORE INTO region_mappings (main_region, adjacent_region, priority_order) VALUES +-- 서울 +('서울', '경기', 1), +('서울', '인천', 2), + +-- 경기 +('경기', '서울', 1), +('경기', '인천', 2), +('경기', '강원', 3), +('경기', '충남', 4), + +-- 인천 +('인천', '서울', 1), +('인천', '경기', 2), + +-- 부산 +('부산', '울산', 1), +('부산', '경남', 2), + +-- 울산 +('울산', '부산', 1), +('울산', '경남', 2), +('울산', '경북', 3), + +-- 대구 +('대구', '경북', 1), +('대구', '경남', 2), + +-- 광주 +('광주', '전남', 1), +('광주', '전북', 2), + +-- 대전 +('대전', '세종', 1), +('대전', '충남', 2), +('대전', '충북', 3), + +-- 세종 +('세종', '대전', 1), +('세종', '충남', 2), +('세종', '충북', 3), + +-- 강원 +('강원', '경기', 1), +('강원', '충북', 2), +('강원', '경북', 3), + +-- 충북 +('충북', '세종', 1), +('충북', '대전', 2), +('충북', '충남', 3), +('충북', '강원', 4), +('충북', '경북', 5), + +-- 충남 +('충남', '세종', 1), +('충남', '대전', 2), +('충남', '경기', 3), +('충남', '전북', 4), + +-- 전북 +('전북', '전남', 1), +('전북', '충남', 2), +('전북', '경남', 3), + +-- 전남 +('전남', '광주', 1), +('전남', '전북', 2), +('전남', '경남', 3), +('전남', '제주', 4), + +-- 경북 +('경북', '대구', 1), +('경북', '경남', 2), +('경북', '강원', 3), + +-- 경남 +('경남', '부산', 1), +('경남', '울산', 2), +('경남', '전남', 3), +('경남', '경북', 4); + +-- 제주는 인접 지역이 없으므로 데이터 없음 (제주만 전남과 연결) + +-- 테이블 코멘트 추가 +ALTER TABLE region_mappings COMMENT = '지역 인접 관계 매핑 테이블 - 버킷 정책 B3에서 사용'; diff --git a/src/main/resources/db/migration/V4__create_region_mappings.sql b/src/main/resources/db/migration/V4__create_region_mappings.sql new file mode 100644 index 00000000..e585ffdf --- /dev/null +++ b/src/main/resources/db/migration/V4__create_region_mappings.sql @@ -0,0 +1,101 @@ +-- 지역 매핑 테이블 생성 +CREATE TABLE IF NOT EXISTS region_mappings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + main_region VARCHAR(20) NOT NULL, + adjacent_region VARCHAR(20) NOT NULL, + priority_order INT NOT NULL, + + -- 인덱스 생성 + INDEX idx_main_region_priority (main_region, priority_order), + + -- 유니크 제약 (중복 매핑 방지) + UNIQUE KEY uk_region_mapping (main_region, adjacent_region) +); + +-- 초기 지역 매핑 데이터 삽입 (중복 방지) +INSERT IGNORE INTO region_mappings (main_region, adjacent_region, priority_order) VALUES +-- 서울 +('서울', '경기', 1), +('서울', '인천', 2), + +-- 경기 +('경기', '서울', 1), +('경기', '인천', 2), +('경기', '강원', 3), +('경기', '충남', 4), + +-- 인천 +('인천', '서울', 1), +('인천', '경기', 2), + +-- 부산 +('부산', '울산', 1), +('부산', '경남', 2), + +-- 울산 +('울산', '부산', 1), +('울산', '경남', 2), +('울산', '경북', 3), + +-- 대구 +('대구', '경북', 1), +('대구', '경남', 2), + +-- 광주 +('광주', '전남', 1), +('광주', '전북', 2), + +-- 대전 +('대전', '세종', 1), +('대전', '충남', 2), +('대전', '충북', 3), + +-- 세종 +('세종', '대전', 1), +('세종', '충남', 2), +('세종', '충북', 3), + +-- 강원 +('강원', '경기', 1), +('강원', '충북', 2), +('강원', '경북', 3), + +-- 충북 +('충북', '세종', 1), +('충북', '대전', 2), +('충북', '충남', 3), +('충북', '강원', 4), +('충북', '경북', 5), + +-- 충남 +('충남', '세종', 1), +('충남', '대전', 2), +('충남', '경기', 3), +('충남', '전북', 4), + +-- 전북 +('전북', '전남', 1), +('전북', '충남', 2), +('전북', '경남', 3), + +-- 전남 +('전남', '광주', 1), +('전남', '전북', 2), +('전남', '경남', 3), +('전남', '제주', 4), + +-- 경북 +('경북', '대구', 1), +('경북', '경남', 2), +('경북', '강원', 3), + +-- 경남 +('경남', '부산', 1), +('경남', '울산', 2), +('경남', '전남', 3), +('경남', '경북', 4); + +-- 제주는 인접 지역이 없으므로 데이터 없음 (제주만 전남과 연결) + +-- 테이블 코멘트 추가 +ALTER TABLE region_mappings COMMENT = '지역 인접 관계 매핑 테이블 - 버킷 정책 B3에서 사용'; diff --git a/src/main/resources/db/migration/V5__update_recommendation_type_constraint.sql b/src/main/resources/db/migration/V5__update_recommendation_type_constraint.sql new file mode 100644 index 00000000..c66a65c4 --- /dev/null +++ b/src/main/resources/db/migration/V5__update_recommendation_type_constraint.sql @@ -0,0 +1,23 @@ +-- 기존 체크 제약 조건 제거 및 새로운 제약 조건 추가 + +-- 기존 체크 제약 조건 확인 및 제거 +SET @constraint_exists := ( + SELECT COUNT(*) + FROM information_schema.table_constraints + WHERE constraint_schema = database() + AND table_name = 'recommendation_history' + AND constraint_name = 'chk_recommendation_type' +); + +SET @sqlstmt := IF(@constraint_exists > 0, + 'ALTER TABLE recommendation_history DROP CONSTRAINT chk_recommendation_type', + 'SELECT ''Constraint does not exist.'' AS msg' +); +PREPARE stmt FROM @sqlstmt; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 새로운 체크 제약 조건 추가 +ALTER TABLE recommendation_history +ADD CONSTRAINT chk_recommendation_type +CHECK (recommendation_type IN ('DAILY_CODE_MATCHING', 'CODE_TIME')); diff --git a/src/main/resources/db/migration/V6__Create_Recommendation_Config_Table.sql b/src/main/resources/db/migration/V6__Create_Recommendation_Config_Table.sql new file mode 100644 index 00000000..33007d97 --- /dev/null +++ b/src/main/resources/db/migration/V6__Create_Recommendation_Config_Table.sql @@ -0,0 +1,18 @@ +-- 추천 시스템 설정 테이블 생성 +CREATE TABLE IF NOT EXISTS recommendation_config +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + daily_code_count INT NOT NULL DEFAULT 3 COMMENT '오늘의 코드매칭 추천 인원 수', + code_time_count INT NOT NULL DEFAULT 2 COMMENT '코드타임 추천 인원 수', + code_time_slots VARCHAR(500) NOT NULL DEFAULT '10:00,22:00' COMMENT '코드타임 시간대 목록', + daily_refresh_time VARCHAR(5) NOT NULL DEFAULT '00:00' COMMENT '오늘의 코드매칭 갱신 시점', + repeat_avoid_days INT NOT NULL DEFAULT 3 COMMENT '중복 방지 기간 (일)', + allow_duplicate BOOLEAN NOT NULL DEFAULT TRUE COMMENT '타입 간 중복 허용 여부', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) +) COMMENT '추천 시스템 설정'; + +-- 기본 설정 데이터 삽입 +INSERT INTO recommendation_config +(id, daily_code_count, code_time_count, code_time_slots, daily_refresh_time, repeat_avoid_days, allow_duplicate) +VALUES (1, 3, 2, '10:00,22:00', '00:00', 3, TRUE); diff --git a/src/main/resources/db/migration/V7__remove_unique_constraint_on_chat_id.sql b/src/main/resources/db/migration/V7__remove_unique_constraint_on_chat_id.sql new file mode 100644 index 00000000..941cc62f --- /dev/null +++ b/src/main/resources/db/migration/V7__remove_unique_constraint_on_chat_id.sql @@ -0,0 +1,24 @@ +SET @idx_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'chat_room_member' + AND index_name = 'idx_chat_room_member_chat_id' +); +SET @sql := IF(@idx_exists = 0, + 'CREATE INDEX idx_chat_room_member_chat_id ON chat_room_member (chat_id)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2) 유니크 인덱스가 있으면 드롭 +SET @uk_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'chat_room_member' + AND index_name = 'UKc3bwd8ohk6yni9mjeryembv4g' +); +SET @sql := IF(@uk_exists > 0, + 'ALTER TABLE chat_room_member DROP INDEX UKc3bwd8ohk6yni9mjeryembv4g', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; \ No newline at end of file diff --git a/src/main/resources/db/migration/V8__add_report_status_and_fields.sql b/src/main/resources/db/migration/V8__add_report_status_and_fields.sql new file mode 100644 index 00000000..d6ad6cd2 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_report_status_and_fields.sql @@ -0,0 +1,35 @@ +-- V8: 신고 테이블에 처리 상태 및 관리자 메모 필드 추가 + +-- 1. status 컬럼 추가 (ENUM 타입) +ALTER TABLE report +ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PENDING' +COMMENT '신고 처리 상태: PENDING(미처리), IN_PROGRESS(검토중), RESOLVED(처리완료), DISMISSED(반려), DUPLICATE(중복신고)'; + +-- 2. admin_note 컬럼 추가 (관리자 메모) +ALTER TABLE report +ADD COLUMN admin_note TEXT NULL +COMMENT '관리자가 작성한 처리 메모'; + +-- 3. processed_at 컬럼 추가 (처리 완료 일시) +ALTER TABLE report +ADD COLUMN processed_at DATETIME NULL +COMMENT '신고 처리 완료 일시'; + +-- 4. reason 컬럼 타입 변경 (VARCHAR → TEXT) +ALTER TABLE report +MODIFY COLUMN reason TEXT NOT NULL +COMMENT '신고 사유'; + +-- 5. status 컬럼에 인덱스 추가 (필터링 성능 향상) +CREATE INDEX idx_report_status ON report(status); + +-- 6. created_at 컬럼에 인덱스 추가 (날짜 범위 검색 성능 향상) +CREATE INDEX idx_report_created_at ON report(created_at); + +-- 7. reported_id와 status 복합 인덱스 (피신고자별 상태 조회 최적화) +CREATE INDEX idx_report_reported_status ON report(reported_id, status); + +-- 8. status 값 체크 제약 조건 추가 +ALTER TABLE report +ADD CONSTRAINT chk_report_status +CHECK (status IN ('PENDING', 'IN_PROGRESS', 'RESOLVED', 'DISMISSED', 'DUPLICATE')); \ No newline at end of file diff --git a/src/main/resources/db/migration/V9__add_my_answer_to_signal.sql b/src/main/resources/db/migration/V9__add_my_answer_to_signal.sql new file mode 100644 index 00000000..a72553c2 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_my_answer_to_signal.sql @@ -0,0 +1,5 @@ +-- V9__add_my_answer_to_signal.sql +-- Add myAnswer field to member_signal table + +ALTER TABLE `member_signal` +ADD COLUMN `my_answer` VARCHAR(255) DEFAULT '' NOT NULL AFTER `message`; diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 00000000..f14336c9 Binary files /dev/null and b/src/main/resources/static/favicon.ico differ diff --git a/src/main/resources/static/image/codel-image.png b/src/main/resources/static/image/codel-image.png new file mode 100644 index 00000000..46b4c439 Binary files /dev/null and b/src/main/resources/static/image/codel-image.png differ diff --git a/src/main/resources/static/image/favicon.png b/src/main/resources/static/image/favicon.png new file mode 100644 index 00000000..f14336c9 Binary files /dev/null and b/src/main/resources/static/image/favicon.png differ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 00000000..cf2b358b --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,148 @@ + + + + + + STOMP + + + + + + + + + + + +
+

WebSocket CONNECT

+
+ + +
+ +

SUBSCRIBE

+
+
+ +
+ +

SEND MESSAGE

+
+ + +
+
+ + +
+ + +

MESSAGES

+
+
+ + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/templates/fragments/member-modals.html b/src/main/resources/templates/fragments/member-modals.html new file mode 100644 index 00000000..6291d76f --- /dev/null +++ b/src/main/resources/templates/fragments/member-modals.html @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/fragments/member-profile-images.html b/src/main/resources/templates/fragments/member-profile-images.html new file mode 100644 index 00000000..bf5fadab --- /dev/null +++ b/src/main/resources/templates/fragments/member-profile-images.html @@ -0,0 +1,137 @@ + + + + + +
+
+
+ + 프로필 이미지 +
+
+
+
+ +
+
+
+ 코드 이미지 +
+ + + 완료 + +
+ + + +
+ +

코드 이미지가 없습니다.

+
+
+ + +
+ +
+
+
+ 페이스 이미지 +
+ + + 미완료 + +
+ + + +
+ +

페이스 이미지가 없습니다.

+
+
+ + +
+
+
+
+ 인증 이미지 +
+ + 제출됨 + +
+ + + + + +
+

+ 표준: +

+

+ 제출일: +

+
+
+ +
+
+
+ +

인증 이미지가 제출되지 않았습니다.

+ 기존 회원은 인증 이미지를 제출하지 않았을 수 있습니다. +
+
+
+
+
+
+ + + diff --git a/src/main/resources/templates/fragments/member-profile-tabs.html b/src/main/resources/templates/fragments/member-profile-tabs.html new file mode 100644 index 00000000..4dba794a --- /dev/null +++ b/src/main/resources/templates/fragments/member-profile-tabs.html @@ -0,0 +1,304 @@ + + + + + + + + +
+
+
+
+ 기본 프로필 (Essential) +
+ + + 완료 + +
+ +
+
+
+ 코드네임 + 홍길동 +
+
+ 생년월일 + 1999년 01월 15일 +
+
+ 나이 + 25세 +
+
+
+
+ 시/도 + 서울특별시 +
+
+ 시/군/구 + 강남구 +
+
+ 직업 + 개발자 +
+
+
+ +
+
관심사
+
+ 개발 +
+
+ +
+ + + 완료일: 2024-01-15 14:30 + +
+
+
+ + +
+
+
+
+ 성격/취향 프로필 (Personality) +
+ + + 미완료 + +
+ +
+
+
+ 헤어 길이 + 단발 +
+
+ 체형 + 보통 +
+
+ + 170cm +
+
+ MBTI + ENFP +
+
+
+
+ 음주 + 가끔 +
+
+ 흡연 + 안함 +
+ +
+ 대표 질문 + 응답 있음 +
+
+
+ +
+
스타일
+
+ 캐주얼 +
+
+ +
+
성격
+
+ 외향적 +
+
+ +
+
대표 답변
+
+

대표 답변 내용

+
+
+ +
+ + + 완료일: 2024-01-15 14:30 + +
+
+
+ + +
+ +
+ + +
+
+
+
계정 정보
+
+ 이메일 + user@example.com +
+
+ OAuth 타입 + + KAKAO + +
+ + + + +
+ 가입일 + 2024-01-01 10:00 +
+
+ 정보 수정일 + 2024-01-15 14:30 +
+
+ +
+
자기소개
+
+

안녕하세요! 개발을 좋아하는 개발자입니다.

+
+
+ +

자기소개가 없습니다.

+
+ +
프로필 완성 현황
+
+
+
+
+ + 기본 프로필 +
+ + + 완료 + +
+
+
+
+
+ + 성격/취향 +
+ + + 미완료 + +
+
+
+
+
+ + 히든 프로필 +
+ + + 미완료 + +
+
+
+
+
+
+ + + diff --git a/src/main/resources/templates/fragments/member-scripts.html b/src/main/resources/templates/fragments/member-scripts.html new file mode 100644 index 00000000..71c4ce79 --- /dev/null +++ b/src/main/resources/templates/fragments/member-scripts.html @@ -0,0 +1,262 @@ + + + + + + + + diff --git a/src/main/resources/templates/fragments/member-sidebar.html b/src/main/resources/templates/fragments/member-sidebar.html new file mode 100644 index 00000000..64ccaeff --- /dev/null +++ b/src/main/resources/templates/fragments/member-sidebar.html @@ -0,0 +1,194 @@ + + + + + +
+
+
+
+
+ +
+
+

코드네임

+

이메일

+
+ 상태 + + +
+
+ 67% +
+
+
프로필 완성도
+ + 다음: HIDDEN + +
+
+
+
+
+
+
+
+
+
+
ID
+
123
+
+
+
+
+
나이
+
25세
+
+
+
+
+
가입일
+
2024년 01월 15일
+
+
+
+
+
+
+ + +
+
+
+ + 빠른 작업 +
+
+
+ +
+
+ 이미지 관리 +
+
+ + 이미지 심사하기 + + +
+
+ + +
+
+ 상태 관리 +
+
+ + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+ + 프로필 통계 +
+
+
+
+
+
+
67
+ 완성도 (%) +
+
+
+
+
5
+ 총 이미지 +
+
+
+
+
25
+ 나이 +
+
+
+
+
45
+ 가입 일수 +
+
+
+
+
+ + + + + +
+
+ + +
+
+ + + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 00000000..94111fb9 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,309 @@ + + + + + 관리자 대시보드 + + + + + + +
+
+ + + +
+

관리자 대시보드

+ + +
+
+
+
+
+
전체 회원
+

0

+
+
+
+
+
+
+
+
심사 대기
+

0

+
+
+
+
+
+
+
+
오늘 가입
+

0

+
+
+
+
+
+
+
+
승인률
+

0%

+
+
+
+
+ + +
+
+
+
+
주간 가입자
+

0

+
+
+
+
+
+
+
월간 가입자
+

0

+
+
+
+
+
+
+
승인 완료
+

0

+
+
+
+
+ + +
+
+
+
+
회원 상태별 현황
+
+
+ +
+
+
+
+
+
+
상태별 상세
+
+
+
+
+
+
+
PENDING
+
0
+
+
+
+
+
+
DONE
+
0
+
+
+
+
+
+
REJECT
+
0
+
+
+
+
+
+
SIGNUP
+
0
+
+
+
+
+
+
+
+ + +
+
+
+
+
최근 30일 가입자 추이
+
+
+ +
+
+
+
+ + + +
+
+
+ + + + + + + + diff --git a/src/main/resources/templates/layouts/admin-layout.html b/src/main/resources/templates/layouts/admin-layout.html new file mode 100644 index 00000000..d638a370 --- /dev/null +++ b/src/main/resources/templates/layouts/admin-layout.html @@ -0,0 +1,419 @@ + + + + + code:L 관리자 + + + + + + + +
+ + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 00000000..0992987f --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,28 @@ + + + + + codel 관리자 로그인 + + + +
+ code:l logo + +

+
+ + +

관리자만 접근할 수 있습니다.

+
+
+ + + diff --git a/src/main/resources/templates/memberDetail.html b/src/main/resources/templates/memberDetail.html new file mode 100644 index 00000000..d1473a7c --- /dev/null +++ b/src/main/resources/templates/memberDetail.html @@ -0,0 +1,73 @@ + + + +
+ +
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+
+
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/memberImageReview.html b/src/main/resources/templates/memberImageReview.html new file mode 100644 index 00000000..e634415e --- /dev/null +++ b/src/main/resources/templates/memberImageReview.html @@ -0,0 +1,465 @@ + + + + + 이미지 심사 - code:L 관리자 + + + + + + +
+ +
+
+
+

+ 이미지 심사 +

+

+ 회원님의 프로필 이미지를 심사합니다 +

+
+ +
+
+ + +
+ + 거절할 이미지를 선택하고 각각의 거절 사유를 입력해주세요. + 선택한 이미지만 거절되며, 유저는 해당 이미지만 다시 업로드할 수 있습니다. +
+ + +
+

+ 얼굴 이미지 + +

+ +
+
+ + +
+ 이미지 #1 +
+ +
+
+
+
+ +
+ +

얼굴 이미지가 없습니다

+
+
+ + +
+

+ 코드 이미지 + +

+ +
+
+ + +
+ 이미지 #1 +
+ +
+
+
+
+ +
+ +

코드 이미지가 없습니다

+
+
+ + +
+

+ 인증 이미지 (참고용) + 거절 불가 - 참고만 +

+
+ + 인증 이미지는 거절 대상이 아닙니다. 표준 이미지와 사용자가 제출한 이미지를 비교하여 참고하세요. +
+ +
+ +
+
+ 표준 이미지 +
+
+ 표준 인증 이미지 +
+ +
+
+
+ + +
+
+ 사용자 제출 이미지 +
+
+ 사용자 인증 이미지 +
+ + 제출일: + +
+
+
+
+
+ +
+

+ 인증 이미지 +

+
+ + 인증 이미지가 제출되지 않았습니다. 기존 회원은 인증 이미지를 제출하지 않았을 수 있습니다. +
+
+ + +
+ + +
+
+ + + + + diff --git a/src/main/resources/templates/memberList.html b/src/main/resources/templates/memberList.html new file mode 100644 index 00000000..ce363958 --- /dev/null +++ b/src/main/resources/templates/memberList.html @@ -0,0 +1,700 @@ + + + + + 회원 관리 + + + + + + +
+
+ + + + +
+
+

+ 회원 관리 +

+
+ + +
+
+ + +
+
+
+

0

+ 전체 회원 +
+
+

0

+ 승인 대기 +
+
+

0

+ 승인 완료 +
+
+

0

+ 거부됨 +
+
+

0

+ 핸드폰 인증 완료 +
+
+

0%

+ 승인율 +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + 초기화 + +
+
+
+
+ + + + + +
+
+
+
+
+ + 빠른 작업: + + + +
+
+
+ 0개 선택됨 +
+
+ + +
+
+ + +
+ + + Page 1 of + 1 + (총 0명) + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
ID이메일이름상태가입일빠른 액션
+
+ +
+
+ + # + + + + +
+
+ U +
+ +
+
+ + + 승인 대기 + + + 승인 완료 + + + 거부됨 + + + + + + + +
+ + +
+
+
+ + + +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/questionEditForm.html b/src/main/resources/templates/questionEditForm.html new file mode 100644 index 00000000..309bb5d0 --- /dev/null +++ b/src/main/resources/templates/questionEditForm.html @@ -0,0 +1,130 @@ + + + + + 질문 수정 + + + + + + +
+
+ + + + +
+
+

질문 수정

+ + 목록으로 + +
+ +
+
+
+
+ + +
최대 500자까지 입력 가능합니다.
+
+ +
+ + +
+ +
+ + +
최대 1000자까지 입력 가능합니다.
+
+ +
+
+ + +
체크 해제 시 비활성 상태로 등록됩니다.
+
+
+ +
+ + + 취소 + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/questionForm.html b/src/main/resources/templates/questionForm.html new file mode 100644 index 00000000..3b792c80 --- /dev/null +++ b/src/main/resources/templates/questionForm.html @@ -0,0 +1,126 @@ + + + + + 질문 등록 + + + + + + +
+
+ + + + +
+
+

새 질문 등록

+ + 목록으로 + +
+ +
+
+
+
+ + +
최대 500자까지 입력 가능합니다.
+
+ +
+ + +
+ +
+ + +
최대 1000자까지 입력 가능합니다.
+
+ +
+
+ + +
체크 해제 시 비활성 상태로 등록됩니다.
+
+
+ +
+ + + 취소 + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/questionList.html b/src/main/resources/templates/questionList.html new file mode 100644 index 00000000..98c66441 --- /dev/null +++ b/src/main/resources/templates/questionList.html @@ -0,0 +1,206 @@ + + + + + 질문 관리 + + + + + + +
+
+ + + + +
+
+

질문 관리

+ + 새 질문 등록 + +
+ + + + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID질문 내용카테고리설명상태등록일관리
+ 등록된 질문이 없습니다. +
+
+
+ + +
+
+ + +
+ + + +
+ +
+
+ +
+
+
+
+ + + +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/reportDetail.html b/src/main/resources/templates/reportDetail.html new file mode 100644 index 00000000..edca1ec7 --- /dev/null +++ b/src/main/resources/templates/reportDetail.html @@ -0,0 +1,244 @@ + + + + + + 신고 상세 - CODE-L 관리자 + + + + + + + + +
+
+ ← 신고 목록으로 +

🚨 신고 상세 정보

+ +
+
+ + +
+

📋 신고 기본 정보

+
+
신고 ID
+
1
+ +
신고 일시
+
2025-01-01 12:00:00
+ +
처리 상태
+
+ 미처리 +
+ +
처리 일시
+
-
+
+
+ + +
+
+

👤 신고자

+
+
+ ID + 1 +
+
+ 코드네임 + - +
+
+ 이메일 + email +
+ +
+
+ +
+

🎯 피신고자

+
+
+ ID + 2 +
+
+ 코드네임 + - +
+
+ 이메일 + email +
+ +
+
+
+ + +
+

💬 신고 사유

+
신고 사유 내용
+
+ + +
+

📝 관리자 메모

+
관리자 메모
+
+ + +
+

⚖️ 신고 처리

+
+
+ +
+
+ +
+
+ +
+
+ + + + +
+
+
+ + +
+
+
0
+
이 사용자가 받은 총 신고 횟수
+
+ +

📊 피신고자의 과거 신고 이력

+ + + + + + + + + + + + + + +
신고 ID신고 일시신고자신고 사유처리 상태
과거 신고 이력이 없습니다.
12025-01-01 12:00신고자
사유
상태
+
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/reportList.html b/src/main/resources/templates/reportList.html new file mode 100644 index 00000000..8452663a --- /dev/null +++ b/src/main/resources/templates/reportList.html @@ -0,0 +1,183 @@ + + + + + + 신고 관리 - CODE-L 관리자 + + + + + + + + +
+
+

🚨 신고 관리

+ +
+
+ +
+

전체 신고

0
+

미처리

0
+

처리완료

0
+

오늘 신고

0
+

이번 주

0
+

이번 달

0
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
ID신고일시신고자피신고자신고 사유처리 상태액션
검색 결과가 없습니다.
12025-01-01 12:00 +
신고자
+ email +
+
피신고자
+ email +
신고 사유
미처리상세보기
+ + + +
+

📊 최근 30일 신고 많이 받은 사용자 TOP 10

+
+
+ + 사용자 + email +
+
5
+
+
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/verificationImageForm.html b/src/main/resources/templates/verificationImageForm.html new file mode 100644 index 00000000..b9e6e221 --- /dev/null +++ b/src/main/resources/templates/verificationImageForm.html @@ -0,0 +1,121 @@ + + + + + 표준 인증 이미지 등록 + + + + + + +
+
+ + + + +
+
+

표준 인증 이미지 등록

+ + 목록으로 + +
+ +
+
+
+
+ + +
JPG, PNG, GIF, WEBP 형식 지원 (최대 10MB)
+ +
+ +
+ + +
최대 500자까지 입력 가능합니다.
+
+ +
+ + 취소 + + +
+
+
+
+
+
+
+ + + + + diff --git a/src/main/resources/templates/verificationImageList.html b/src/main/resources/templates/verificationImageList.html new file mode 100644 index 00000000..9c33ab5a --- /dev/null +++ b/src/main/resources/templates/verificationImageList.html @@ -0,0 +1,143 @@ + + + + + 표준 인증 이미지 관리 + + + + + + +
+
+ + + + +
+
+

표준 인증 이미지 관리

+ + 새 이미지 등록 + +
+ + + + + + +
+
+
+ +

등록된 표준 인증 이미지가 없습니다.

+
+ +
+
+
+
+ 표준 이미지 +
+
+ + 활성 + + + 비활성 + +
+

+ ID: +

+

+ 등록일: +

+
+
+ +
+
포즈 설명
+

+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/src/test/kotlin/codel/CodelApplicationTests.kt b/src/test/kotlin/codel/CodelApplicationTests.kt index f5a1ea6b..276623cc 100644 --- a/src/test/kotlin/codel/CodelApplicationTests.kt +++ b/src/test/kotlin/codel/CodelApplicationTests.kt @@ -5,9 +5,7 @@ import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class CodelApplicationTests { - - @Test - fun contextLoads() { - } - + @Test + fun contextLoads() { + } } diff --git a/src/test/kotlin/codel/config/DataCleanerExtension.kt b/src/test/kotlin/codel/config/DataCleanerExtension.kt new file mode 100644 index 00000000..6b18c4e3 --- /dev/null +++ b/src/test/kotlin/codel/config/DataCleanerExtension.kt @@ -0,0 +1,50 @@ +package codel.config + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.ExtensionContext +import org.springframework.beans.factory.NoSuchBeanDefinitionException +import org.springframework.context.ApplicationContext +import org.springframework.core.annotation.AnnotatedElementUtils +import org.springframework.test.context.TestContextAnnotationUtils +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.transaction.annotation.Transactional + +class DataCleanerExtension : + AfterEachCallback, + Loggable { + override fun afterEach(extensionContext: ExtensionContext) { + val applicationContext = SpringExtension.getApplicationContext(extensionContext) + + validateTransactionalAnnotationExists(extensionContext) + cleanDatabase(applicationContext) + } + + private fun validateTransactionalAnnotationExists(extensionContext: ExtensionContext) { + if (TestContextAnnotationUtils.hasAnnotation(extensionContext.requiredTestClass, Transactional::class.java) || + TestContextAnnotationUtils.hasAnnotation( + extensionContext.requiredTestClass, + jakarta.transaction.Transactional::class.java + ) + ) { + Assertions.fail("테스트 클래스에 @Transactional 또는 @jakarta.transaction.Transactional 어노테이션이 존재합니다.") + } + + if (AnnotatedElementUtils.hasAnnotation(extensionContext.requiredTestMethod, Transactional::class.java) || + AnnotatedElementUtils.hasAnnotation( + extensionContext.requiredTestMethod, + jakarta.transaction.Transactional::class.java + ) + ) { + Assertions.fail("테스트 메서드에 @Transactional 또는 @jakarta.transaction.Transactional 어노테이션이 존재합니다.") + } + } + + private fun cleanDatabase(applicationContext: ApplicationContext) { + try { + DatabaseCleaner.clear(applicationContext) + } catch (e: NoSuchBeanDefinitionException) { + log.debug { "Database Cleaning not supported." } + } + } +} diff --git a/src/test/kotlin/codel/config/DatabaseCleaner.kt b/src/test/kotlin/codel/config/DatabaseCleaner.kt new file mode 100644 index 00000000..8cd687f0 --- /dev/null +++ b/src/test/kotlin/codel/config/DatabaseCleaner.kt @@ -0,0 +1,37 @@ +package codel.config + +import jakarta.persistence.EntityManager +import org.springframework.context.ApplicationContext +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.transaction.support.TransactionTemplate + +object DatabaseCleaner { + fun clear(applicationContext: ApplicationContext) { + val entityManager = applicationContext.getBean(EntityManager::class.java) + val jdbcTemplate = applicationContext.getBean(JdbcTemplate::class.java) + val transactionTemplate = applicationContext.getBean(TransactionTemplate::class.java) + + transactionTemplate.execute { + entityManager.clear() + deleteAll(jdbcTemplate, entityManager) + null + } + } + + private fun deleteAll( + jdbcTemplate: JdbcTemplate, + entityManager: EntityManager, + ) { + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate() + + findDatabaseTableNames(jdbcTemplate).forEach { tableName -> + entityManager.createNativeQuery("DELETE FROM $tableName").executeUpdate() + entityManager.createNativeQuery("ALTER TABLE $tableName ALTER COLUMN id RESTART WITH 1").executeUpdate() + } + + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate() + } + + private fun findDatabaseTableNames(jdbcTemplate: JdbcTemplate): List = + jdbcTemplate.query("SHOW TABLES") { rs, _ -> rs.getString(1) } +} diff --git a/src/test/kotlin/codel/config/argumentresolver/MemberArgumentResolverTest.kt b/src/test/kotlin/codel/config/argumentresolver/MemberArgumentResolverTest.kt new file mode 100644 index 00000000..1021bbb5 --- /dev/null +++ b/src/test/kotlin/codel/config/argumentresolver/MemberArgumentResolverTest.kt @@ -0,0 +1,61 @@ +package codel.config.argumentresolver + +import codel.member.business.MemberService +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import jakarta.servlet.http.HttpServletRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.core.MethodParameter +import org.springframework.web.context.request.NativeWebRequest + +class MemberArgumentResolverTest { + private lateinit var memberService: MemberService + private lateinit var resolver: MemberArgumentResolver + + @BeforeEach + fun setUp() { + memberService = mock(MemberService::class.java) + resolver = MemberArgumentResolver(memberService) + } + + @DisplayName("ArgumentResolver는 Member 정보를 반환한다.") + @Test + fun resolveArgumentTest() { + val memberId = 1L + val oauthId = "seok" + val oauthType = OauthType.KAKAO + + val fakeMember = + Member( + id = memberId, + oauthId = oauthId, + oauthType = oauthType, + memberStatus = MemberStatus.SIGNUP, + email = "hogee@hogee", + ) + + val httpRequest = mock(HttpServletRequest::class.java) + `when`(httpRequest.getAttribute("memberId")).thenReturn(memberId.toString()) + + val webRequest = mock(NativeWebRequest::class.java) + `when`(webRequest.getNativeRequest(HttpServletRequest::class.java)).thenReturn(httpRequest) + + `when`(memberService.findMember(memberId)).thenReturn(fakeMember) + + val result = + resolver.resolveArgument( + mock(MethodParameter::class.java), + null, + webRequest, + null, + ) + + assertEquals(fakeMember, result) + } +} diff --git a/src/test/kotlin/codel/member/business/signup/PostVerificationStrategyTest.kt b/src/test/kotlin/codel/member/business/signup/PostVerificationStrategyTest.kt new file mode 100644 index 00000000..53547105 --- /dev/null +++ b/src/test/kotlin/codel/member/business/signup/PostVerificationStrategyTest.kt @@ -0,0 +1,110 @@ +package codel.member.business.signup + +import codel.member.business.SignupService +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.* +import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile + +class PostVerificationStrategyTest { + + private lateinit var signupService: SignupService + private lateinit var strategy: PostVerificationStrategy + + @BeforeEach + fun setUp() { + signupService = mock(SignupService::class.java) + strategy = PostVerificationStrategy(signupService) + } + + @DisplayName("히든 이미지를 등록한다") + @Test + fun handleHiddenImages_registerImages() { + // given + val member = Member( + id = 1L, + oauthId = "test-oauth-id", + oauthType = OauthType.KAKAO, + memberStatus = MemberStatus.PERSONALITY_COMPLETED, + email = "test@test.com" + ) + + val images = listOf( + MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()), + MockMultipartFile("image2", "test2.jpg", "image/jpeg", "test2".toByteArray()), + MockMultipartFile("image3", "test3.jpg", "image/jpeg", "test3".toByteArray()) + ) + + // when + val response = strategy.handleHiddenImages(member, images) + + // then + verify(signupService, times(1)).registerHiddenImages(member, images) + assertEquals(HttpStatus.OK, response.statusCode) + } + + @DisplayName("회원 상태를 변경하지 않는다") + @Test + fun handleHiddenImages_noStatusChange() { + // given + val initialStatus = MemberStatus.PERSONALITY_COMPLETED + val member = Member( + id = 1L, + oauthId = "test-oauth-id", + oauthType = OauthType.KAKAO, + memberStatus = initialStatus, + email = "test@test.com" + ) + + val images = listOf( + MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()) + ) + + // when + strategy.handleHiddenImages(member, images) + + // then + assertEquals(initialStatus, member.memberStatus) + } + + @DisplayName("다양한 회원 상태에서 모두 동일하게 동작한다") + @Test + fun handleHiddenImages_differentMemberStatuses() { + // given + val statuses = listOf( + MemberStatus.PERSONALITY_COMPLETED, + MemberStatus.REJECT, + MemberStatus.PENDING, + MemberStatus.DONE + ) + + val images = listOf( + MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()) + ) + + // when & then + statuses.forEach { status -> + val member = Member( + id = 1L, + oauthId = "test-oauth-id", + oauthType = OauthType.KAKAO, + memberStatus = status, + email = "test@test.com" + ) + + val response = strategy.handleHiddenImages(member, images) + + verify(signupService, times(1)).registerHiddenImages(member, images) + assertEquals(status, member.memberStatus) // 상태 유지 + assertEquals(HttpStatus.OK, response.statusCode) + + reset(signupService) + } + } +} diff --git a/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt new file mode 100644 index 00000000..e607fbc1 --- /dev/null +++ b/src/test/kotlin/codel/member/business/signup/PreVerificationStrategyTest.kt @@ -0,0 +1,61 @@ +package codel.member.business.signup + +import codel.member.business.SignupService +import codel.member.domain.Member +import codel.member.domain.MemberStatus +import codel.member.domain.OauthType +import codel.member.infrastructure.MemberJpaRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.* +import org.springframework.http.HttpStatus +import org.springframework.mock.web.MockMultipartFile + +class PreVerificationStrategyTest { + + private lateinit var signupService: SignupService + private lateinit var memberJpaRepository: MemberJpaRepository + private lateinit var strategy: PreVerificationStrategy + + @BeforeEach + fun setUp() { + signupService = mock(SignupService::class.java) + memberJpaRepository = mock(MemberJpaRepository::class.java) + strategy = PreVerificationStrategy(signupService, memberJpaRepository) + } + + @DisplayName("PERSONALITY_COMPLETED 상태에서는 히든 이미지 등록 후 HIDDEN_COMPLETED 상태로 변경한다") + @Test + fun handleHiddenImages_personalityCompleted_changeToHiddenCompleted() { + // given + val member = Member( + id = 1L, + oauthId = "test-oauth-id", + oauthType = OauthType.KAKAO, + memberStatus = MemberStatus.PERSONALITY_COMPLETED, + email = "test@test.com" + ) + + val images = listOf( + MockMultipartFile("image1", "test1.jpg", "image/jpeg", "test1".toByteArray()), + MockMultipartFile("image2", "test2.jpg", "image/jpeg", "test2".toByteArray()), + MockMultipartFile("image3", "test3.jpg", "image/jpeg", "test3".toByteArray()) + ) + + // when + val response = strategy.handleHiddenImages(member, images) + + // then + verify(signupService, times(1)).registerHiddenImages(member, images) + + val memberCaptor = ArgumentCaptor.forClass(Member::class.java) + verify(memberJpaRepository, times(1)).save(memberCaptor.capture()) + + val savedMember = memberCaptor.value + assertEquals(MemberStatus.HIDDEN_COMPLETED, savedMember.memberStatus) + assertEquals(HttpStatus.OK, response.statusCode) + } +} diff --git a/src/test/kotlin/codel/member/business/signup/SignupStrategyResolverTest.kt b/src/test/kotlin/codel/member/business/signup/SignupStrategyResolverTest.kt new file mode 100644 index 00000000..7ccd0261 --- /dev/null +++ b/src/test/kotlin/codel/member/business/signup/SignupStrategyResolverTest.kt @@ -0,0 +1,152 @@ +package codel.member.business.signup + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +class SignupStrategyResolverTest { + + private lateinit var postVerificationStrategy: PostVerificationStrategy + private lateinit var preVerificationStrategy: PreVerificationStrategy + private lateinit var resolver: SignupStrategyResolver + + @BeforeEach + fun setUp() { + postVerificationStrategy = mock(PostVerificationStrategy::class.java) + preVerificationStrategy = mock(PreVerificationStrategy::class.java) + resolver = SignupStrategyResolver(postVerificationStrategy, preVerificationStrategy) + } + + @DisplayName("앱 버전이 1.2.0 이상이면 PostVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_2_0() { + // given + val appVersion = "1.2.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(postVerificationStrategy, result) + } + + @DisplayName("앱 버전이 1.5.0이면 PostVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_5_0() { + // given + val appVersion = "1.5.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(postVerificationStrategy, result) + } + + @DisplayName("앱 버전이 2.0.0이면 PostVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_2_0_0() { + // given + val appVersion = "2.0.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(postVerificationStrategy, result) + } + + @DisplayName("앱 버전이 1.1.9이면 PreVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_1_9() { + // given + val appVersion = "1.1.9" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } + + @DisplayName("앱 버전이 1.0.0이면 PreVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_0_0() { + // given + val appVersion = "1.0.0" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } + + @DisplayName("앱 버전이 null이면 PreVerificationStrategy를 반환한다 (하위호환)") + @Test + fun resolveStrategy_version_null() { + // given + val appVersion: String? = null + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } + + @DisplayName("앱 버전 파싱 실패 시 PreVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_invalid_version() { + // given + val appVersion = "invalid-version" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } + + @DisplayName("앱 버전이 빈 문자열이면 PreVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_empty_version() { + // given + val appVersion = "" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } + + @DisplayName("앱 버전이 1.2 형식이면 정상 파싱하여 PostVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_2() { + // given + val appVersion = "1.2" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(postVerificationStrategy, result) + } + + @DisplayName("앱 버전이 1.1 형식이면 정상 파싱하여 PreVerificationStrategy를 반환한다") + @Test + fun resolveStrategy_version_1_1() { + // given + val appVersion = "1.1" + + // when + val result = resolver.resolveStrategy(appVersion) + + // then + assertEquals(preVerificationStrategy, result) + } +} diff --git a/src/test/kotlin/codel/recommendation/business/TimeZoneServiceTest.kt b/src/test/kotlin/codel/recommendation/business/TimeZoneServiceTest.kt new file mode 100644 index 00000000..52d4c1e5 --- /dev/null +++ b/src/test/kotlin/codel/recommendation/business/TimeZoneServiceTest.kt @@ -0,0 +1,237 @@ +package codel.recommendation.business + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.* + +@DisplayName("TimeZoneService 테스트") +class TimeZoneServiceTest { + + private val timeZoneService = TimeZoneService() + + @Test + @DisplayName("기본 타임존은 Asia/Seoul이다") + fun getDefaultTimeZone() { + // when + val zone = timeZoneService.getTimeZone() + + // then + assertEquals(ZoneId.of("Asia/Seoul"), zone) + } + + @Test + @DisplayName("유효한 타임존 ID를 받으면 해당 타임존을 반환한다") + fun getValidTimeZone() { + // when + val nyZone = timeZoneService.getTimeZone("America/New_York") + val londonZone = timeZoneService.getTimeZone("Europe/London") + + // then + assertEquals(ZoneId.of("America/New_York"), nyZone) + assertEquals(ZoneId.of("Europe/London"), londonZone) + } + + @Test + @DisplayName("유효하지 않은 타임존 ID를 받으면 기본 타임존을 반환한다") + fun getInvalidTimeZone() { + // when + val zone = timeZoneService.getTimeZone("Invalid/Zone") + + // then + assertEquals(ZoneId.of("Asia/Seoul"), zone) + } + + @Test + @DisplayName("KST 기준 오늘 자정의 UTC 시각을 반환한다") + fun getTodayStartInUTC_KST() { + // given + val kstZone = ZoneId.of("Asia/Seoul") + val kstToday = LocalDate.now(kstZone) + val expectedUTC = kstToday.atStartOfDay(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + // when + val result = timeZoneService.getTodayStartInUTC() + + // then + assertEquals(expectedUTC.toLocalDate(), result.toLocalDate()) + assertEquals(expectedUTC.hour, result.hour) + } + + @Test + @DisplayName("뉴욕 기준 오늘 자정의 UTC 시각을 반환한다") + fun getTodayStartInUTC_NewYork() { + // given + val nyZone = ZoneId.of("America/New_York") + val nyToday = LocalDate.now(nyZone) + val expectedUTC = nyToday.atStartOfDay(nyZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + // when + val result = timeZoneService.getTodayStartInUTC("America/New_York") + + // then + assertEquals(expectedUTC.toLocalDate(), result.toLocalDate()) + assertEquals(expectedUTC.hour, result.hour) + } + + @Test + @DisplayName("KST 기준 10시 슬롯의 UTC 시간 범위를 반환한다") + fun getTimeSlotRangeInUTC_10AM_KST() { + // given + val kstZone = ZoneId.of("Asia/Seoul") + val kstToday = LocalDate.now(kstZone) + + val expectedStart = kstToday.atTime(10, 0).atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + val expectedEnd = kstToday.atTime(22, 0).atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + // when + val (start, end) = timeZoneService.getTimeSlotRangeInUTC("10:00") + + // then + assertEquals(expectedStart, start) + assertEquals(expectedEnd, end) + } + + @Test + @DisplayName("KST 기준 22시 슬롯의 UTC 시간 범위를 반환한다 - 현재가 22시 이후인 경우") + fun getTimeSlotRangeInUTC_10PM_KST_After22() { + // given + val kstZone = ZoneId.of("Asia/Seoul") + val kstNow = LocalDateTime.now(kstZone) + + // 22시 이후인지 확인 + if (kstNow.hour >= 22) { + val kstToday = kstNow.toLocalDate() + + val expectedStart = kstToday.atTime(22, 0).atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + val expectedEnd = kstToday.plusDays(1).atTime(10, 0).atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + // when + val (start, end) = timeZoneService.getTimeSlotRangeInUTC("22:00") + + // then + assertEquals(expectedStart, start) + assertEquals(expectedEnd, end) + } + } + + @Test + @DisplayName("KST 기준 현재 활성 시간대를 반환한다 - 10시~21시는 10:00 슬롯") + fun getCurrentTimeSlot_Morning_KST() { + // given + val kstZone = ZoneId.of("Asia/Seoul") + val kstNow = LocalDateTime.now(kstZone) + val currentHour = kstNow.hour + + // when + val result = timeZoneService.getCurrentTimeSlot() + + // then + if (currentHour in 10..21) { + assertEquals("10:00", result) + } else { + assertEquals("22:00", result) + } + } + + @Test + @DisplayName("다양한 타임존에서 현재 시간대를 올바르게 반환한다") + fun getCurrentTimeSlot_VariousTimezones() { + // 뉴욕 + val nyTimeSlot = timeZoneService.getCurrentTimeSlot("America/New_York") + assertTrue(nyTimeSlot in listOf("10:00", "22:00")) + + // 런던 + val londonTimeSlot = timeZoneService.getCurrentTimeSlot("Europe/London") + assertTrue(londonTimeSlot in listOf("10:00", "22:00")) + + // 도쿄 + val tokyoTimeSlot = timeZoneService.getCurrentTimeSlot("Asia/Tokyo") + assertTrue(tokyoTimeSlot in listOf("10:00", "22:00")) + } + + @Test + @DisplayName("UTC 시각을 KST로 변환한다") + fun convertUTCToZone_KST() { + // given + val utcDateTime = LocalDateTime.of(2025, 10, 28, 15, 0) // UTC + val kstZone = ZoneId.of("Asia/Seoul") + + val expected = utcDateTime.atZone(ZoneOffset.UTC) + .withZoneSameInstant(kstZone) + .toLocalDateTime() + + // when + val result = timeZoneService.convertUTCToZone(utcDateTime) + + // then + assertEquals(expected, result) + assertEquals(LocalDateTime.of(2025, 10, 29, 0, 0), result) // KST는 UTC+9 + } + + @Test + @DisplayName("KST 시각을 UTC로 변환한다") + fun convertZoneToUTC_KST() { + // given + val kstDateTime = LocalDateTime.of(2025, 10, 29, 0, 0) // KST 자정 + val kstZone = ZoneId.of("Asia/Seoul") + + val expected = kstDateTime.atZone(kstZone) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + + // when + val result = timeZoneService.convertZoneToUTC(kstDateTime) + + // then + assertEquals(expected, result) + assertEquals(LocalDateTime.of(2025, 10, 28, 15, 0), result) // UTC는 KST-9 + } + + @Test + @DisplayName("22시 슬롯은 날짜를 넘어가는 시간 범위를 반환한다") + fun getTimeSlotRangeInUTC_22PM_CrossesDate() { + // given & when + val (start, end) = timeZoneService.getTimeSlotRangeInUTC("22:00") + + // then + // 22시 슬롯은 최소 12시간 (22:00 ~ 다음날 10:00) + val duration = Duration.between(start, end) + assertEquals(12, duration.toHours()) + } + + @Test + @DisplayName("잘못된 시간대 슬롯을 입력하면 예외가 발생한다") + fun getTimeSlotRangeInUTC_InvalidSlot() { + // when & then + assertThrows(IllegalArgumentException::class.java) { + timeZoneService.getTimeSlotRangeInUTC("15:00") + } + } + + @Test + @DisplayName("오늘과 내일 자정 사이는 24시간이다") + fun getTodayAndTomorrowDifference() { + // when + val todayStart = timeZoneService.getTodayStartInUTC() + val tomorrowStart = timeZoneService.getTomorrowStartInUTC() + + // then + val duration = Duration.between(todayStart, tomorrowStart) + assertEquals(24, duration.toHours()) + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..74ccdf60 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,58 @@ +server: + port: 8080 + +security: + jwt: + token: + expire-length: 3600000 + secret-key: dummy-secret-key-should-be-long-enough-123456 + admin: + password: dummy-admin-password + +cloud: + aws: + region: + static: ap-northeast-2 + s3: + bucket: dummy-code-l-bucket + credentials: + access-key: dummy-access-key + secret-key: dummy-secret-key + +management: + endpoints: + web: + exposure: + include: health, metrics, prometheus + metrics: + enable: + all: true + +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + show-sql: true + database-platform: org.hibernate.dialect.H2Dialect + + flyway: + enabled: false + + h2: + console: + enabled: true + +springdoc: + override-with-generic-response: false + +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type.descriptor.sql.BasicBinder: trace \ No newline at end of file