diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..c2a17e4b15 --- /dev/null +++ b/.env.example @@ -0,0 +1,73 @@ +# JWT +JWT_SECRET_KEY=your-jwt-secret-key-example-32chars!! +JWT_ACCESS_TOKEN_EXPIRATION_TIME=6000000 + +# Spring +SPRING_PROFILES_ACTIVE=dev + +# Database +DATASOURCE_URL=jdbc:mysql://127.0.0.1:3306/koin?characterEncoding=utf8&useUnicode=true&mysqlEncoding=utf8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true +DATASOURCE_USERNAME=koin +DATASOURCE_PASSWORD=your-db-password + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=your-redis-password + +# MongoDB +MONGODB_URI=mongodb://koin:your-mongo-password@127.0.0.1:27017/koin?authSource=admin + +# Swagger +SWAGGER_SERVER_URL=http://localhost:8080 + +# AWS SES +AWS_SES_ACCESS_KEY=test +AWS_SES_SECRET_KEY=test + +# S3 +S3_BUCKET=your-bucket-name +S3_CUSTOM_DOMAIN=https://static.example.com/ + +# Slack +SLACK_KOIN_EVENT_NOTIFY_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +SLACK_KOIN_OWNER_EVENT_NOTIFY_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +SLACK_KOIN_SHOP_REVIEW_NOTIFY_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +SLACK_KOIN_LOST_ITEM_NOTIFY_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL +SLACK_LOGGING_ERROR_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL + +# Koin Admin +KOIN_ADMIN_SHOP_URL=https://admin.example.com/store +KOIN_ADMIN_REVIEW_URL=https://admin.example.com/review + +# Open API +OPEN_API_KEY_PUBLIC=your-open-api-key-public +OPEN_API_KEY_TMONEY=your-open-api-key-tmoney + +# FCM +FCM_KOIN_URL=example:// + +# Naver SMS +NAVER_ACCESS_KEY=your-naver-access-key +NAVER_SECRET_KEY=your-naver-secret-key +NAVER_SMS_API_URL=https://sens.apigw.ntruss.com +NAVER_SMS_SERVICE_ID=your-service-id +NAVER_SMS_FROM_NUMBER=01000000000 + +# KOIN VERIFICATION +MAX_VERIFICATION_COUNT=5 + +# CORS (콤마로 구분) +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,https://example.com + +# CloudFront +CLOUDFRONT_DISTRIBUTION_ID=test +CLOUDFRONT_REGION=ap-northeast-2 + +# Address API +ADDRESS_API_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do +ADDRESS_API_KEY=your-address-api-key + +# Toss Payment +TOSS_PAYMENT_SECRET_KEY=test_sk_your-toss-secret-key +TOSS_PAYMENT_API_BASE_URL=https://api.tosspayments.com/v1/payments diff --git a/.github/workflows/backend-cd-develop.yml b/.github/workflows/backend-cd-develop.yml new file mode 100644 index 0000000000..8fca1b9f61 --- /dev/null +++ b/.github/workflows/backend-cd-develop.yml @@ -0,0 +1,91 @@ +name: KOIN_API_V2 CD (develop) + +on: + push: + branches: + - develop + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Record start time + run: echo "START_TIME=$(date +%s)" >> $GITHUB_ENV + + - name: Notify Slack - Deploy Start + env: + ACTIONS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + COMMIT_MSG=$(git log -1 --pretty=%s HEAD) + curl -X POST ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} \ + -H 'Content-Type: application/json' \ + -d "{ + \"text\": \":rocket: *[Develop] 배포 시작*\n• Repo: ${{ github.repository }}\n• Branch: develop\n• Author: @${{ github.actor }}\n• Commit: ${COMMIT_MSG}\n• <${ACTIONS_URL}|Actions 보기>\" + }" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + + - name: Create Firebase Admin SDK JSON + run: echo '${{ secrets.FCM_ADMIN_SDK_JSON_DEVELOP }}' > src/main/resources/koin-firebase-adminsdk.json + + - name: Build JAR + run: | + set -a + source .env.example + set +a + ./gradlew clean build -x test -Dspring.profiles.active=dev + + - name: SCP JAR to develop server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEVELOP_SERVER_HOST }} + username: ${{ secrets.DEVELOP_SERVER_USER }} + key: ${{ secrets.DEVELOP_SSH_PRIVATE_KEY }} + port: ${{ secrets.DEVELOP_SERVER_PORT }} + source: ${{ secrets.SOURCE_JAR_PATH }} + target: ${{ secrets.DEVELOP_SERVER_JAR_PATH }} + strip_components: 2 + + - name: Run deploy script on develop server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEVELOP_SERVER_HOST }} + username: ${{ secrets.DEVELOP_SERVER_USER }} + key: ${{ secrets.DEVELOP_SSH_PRIVATE_KEY }} + port: ${{ secrets.DEVELOP_SERVER_PORT }} + script: ${{ secrets.DEVELOP_DEPLOY_SCRIPT_PATH }} + + - name: Notify Slack - Deploy Result + if: always() + env: + ACTIONS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + COMMIT_MSG=$(git log -1 --pretty=%s HEAD) + DURATION_SEC=$(( $(date +%s) - START_TIME )) + DURATION="${DURATION_SEC}초 (약 $(( DURATION_SEC / 60 ))분)" + if [ "${{ job.status }}" = "success" ]; then + ICON=":white_check_mark:" + STATUS="배포 성공" + else + ICON=":x:" + STATUS="배포 실패" + fi + curl -X POST ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} \ + -H 'Content-Type: application/json' \ + -d "{ + \"text\": \"${ICON} *[Develop] ${STATUS}*\n• Repo: ${{ github.repository }}\n• Branch: develop\n• Author: @${{ github.actor }}\n• Commit: ${COMMIT_MSG}\n• Duration: ${DURATION}\n• <${ACTIONS_URL}|Actions 보기>\" + }" diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml new file mode 100644 index 0000000000..5efbb09311 --- /dev/null +++ b/.github/workflows/backend-cd-main.yml @@ -0,0 +1,89 @@ +name: KOIN_API_V2 CD (main) + +on: + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Record start time + run: echo "START_TIME=$(date +%s)" >> $GITHUB_ENV + + - name: Notify Slack - Deploy Start + env: + ACTIONS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + COMMIT_MSG=$(git log -1 --pretty=%s HEAD) + curl -X POST ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} \ + -H 'Content-Type: application/json' \ + -d "{ + \"text\": \":rocket: *[Production] 배포 시작*\n• Repo: ${{ github.repository }}\n• Branch: main\n• Author: @${{ github.actor }}\n• Commit: ${COMMIT_MSG}\n• <${ACTIONS_URL}|Actions 보기>\" + }" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} + + - name: Create Firebase Admin SDK JSON + run: echo '${{ secrets.FCM_ADMIN_SDK_JSON_MAIN }}' > src/main/resources/koin-firebase-adminsdk.json + + - name: Build JAR + run: | + set -a + source .env.example + set +a + ./gradlew clean build -x test -Dspring.profiles.active=prod + + - name: SCP JAR to main server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.MAIN_SERVER_HOST }} + username: ${{ secrets.MAIN_SERVER_USER }} + key: ${{ secrets.MAIN_SSH_PRIVATE_KEY }} + port: ${{ secrets.MAIN_SERVER_PORT }} + source: ${{ secrets.SOURCE_JAR_PATH }} + target: ${{ secrets.MAIN_SERVER_JAR_PATH }} + strip_components: 2 + + - name: Run deploy script on main server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.MAIN_SERVER_HOST }} + username: ${{ secrets.MAIN_SERVER_USER }} + key: ${{ secrets.MAIN_SSH_PRIVATE_KEY }} + port: ${{ secrets.MAIN_SERVER_PORT }} + script: ${{ secrets.MAIN_DEPLOY_SCRIPT_PATH }} + + - name: Notify Slack - Deploy Result + if: always() + env: + ACTIONS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + COMMIT_MSG=$(git log -1 --pretty=%s HEAD) + DURATION_SEC=$(( $(date +%s) - START_TIME )) + DURATION="${DURATION_SEC}초 (약 $(( DURATION_SEC / 60 ))분)" + if [ "${{ job.status }}" = "success" ]; then + ICON=":white_check_mark:" + STATUS="배포 성공" + else + ICON=":x:" + STATUS="배포 실패" + fi + curl -X POST ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} \ + -H 'Content-Type: application/json' \ + -d "{ + \"text\": \"${ICON} *[Production] ${STATUS}*\n• Repo: ${{ github.repository }}\n• Branch: main\n• Author: @${{ github.actor }}\n• Commit: ${COMMIT_MSG}\n• Duration: ${DURATION}\n• <${ACTIONS_URL}|Actions 보기>\" + }" diff --git a/.gitignore b/.gitignore index b4ddde3f78..f72ee67f91 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,8 @@ out/ .vscode/ .DS_STORE -application.yml *adminsdk.json +.env* +!.env.example logs diff --git a/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportApi.java b/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportApi.java new file mode 100644 index 0000000000..10b3fec5a5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportApi.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.admin.callvan.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.*; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportProcessRequest; +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportsResponse; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.ApiResponseCodes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Callvan: 신고 처리", description = "어드민 콜벤 사용자 신고 내역 관리") +@RequestMapping("/admin/callvan/reports") +public interface AdminCallvanReportApi { + + @ApiResponseCodes({ + OK, + UNAUTHORIZED_USER, + FORBIDDEN_ADMIN + }) + @Operation(summary = "콜벤 신고 목록 조회", description = """ + 콜벤 사용자 신고 접수 목록을 조회합니다. + - `only_pending=true` 이면 미처리 신고(PENDING)만 조회합니다. + - 각 항목에는 피신고자 정보, 신고 사유, 첨부 이미지, 처리 유형, 누적 신고 이력이 포함됩니다. + """) + @GetMapping + ResponseEntity getCallvanReports( + @RequestParam(name = "only_pending", required = false, defaultValue = "false") Boolean onlyPending, + @RequestParam(name = "page", required = false) Integer page, + @RequestParam(name = "limit", required = false) Integer limit, + @Auth(permit = {ADMIN}) Integer adminId); + + @ApiResponseCodes({ + OK, + UNAUTHORIZED_USER, + FORBIDDEN_ADMIN, + INVALID_REQUEST_BODY, + NOT_FOUND_CALLVAN_REPORT, + CALLVAN_REPORT_ALREADY_PROCESSED + }) + @Operation(summary = "콜벤 신고 처리", description = """ + 콜벤 신고를 처리합니다. + - `WARNING`: 신고 확정 후 주의 안내 알림을 발송합니다. + - `TEMPORARY_RESTRICTION_14_DAYS`: 신고 확정 후 14일간 새 모집/참여를 제한하며, 제재 알림을 발송합니다. + - `PERMANENT_RESTRICTION`: 신고 확정 후 콜벤 기능을 영구 제한하며, 제재 알림을 발송합니다. + - `REJECT`: 신고를 반려하고 상태를 REJECTED로 변경합니다. 알림은 발송되지 않습니다. + + #### 제재 유형별 알림 메시지 + | 처리 유형 | 알림 타입 | 메시지 | + | :--- | :--- | :--- | + | `WARNING` | `REPORT_WARNING` | 콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 주의 안내가 전달되었습니다. 이후 동일한 문제가 반복될 경우 콜벤 기능 이용이 제한될 수 있습니다. | + | `TEMPORARY_RESTRICTION_14_DAYS` | `REPORT_RESTRICTION_14_DAYS` | 콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 14일간 콜벤 기능 이용이 제한되었습니다. | + | `PERMANENT_RESTRICTION` | `REPORT_PERMANENT_RESTRICTION` | 콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 콜벤 기능 이용이 영구적으로 제한되었습니다. | + """) + @PostMapping("/{reportId}/process") + ResponseEntity processCallvanReport( + @Parameter(in = PATH) @PathVariable Integer reportId, + @RequestBody @Valid AdminCallvanReportProcessRequest request, + @Auth(permit = {ADMIN}) Integer adminId); +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportController.java b/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportController.java new file mode 100644 index 0000000000..3c126503be --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/controller/AdminCallvanReportController.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.admin.callvan.controller; + +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportProcessRequest; +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportsResponse; +import in.koreatech.koin.admin.callvan.service.AdminCallvanReportQueryService; +import in.koreatech.koin.admin.callvan.service.AdminCallvanReportService; +import in.koreatech.koin.global.auth.Auth; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/admin/callvan/reports") +@RequiredArgsConstructor +public class AdminCallvanReportController implements AdminCallvanReportApi { + + private final AdminCallvanReportService adminCallvanReportService; + private final AdminCallvanReportQueryService adminCallvanReportQueryService; + + @GetMapping + public ResponseEntity getCallvanReports( + @RequestParam(name = "only_pending", required = false, defaultValue = "false") Boolean onlyPending, + @RequestParam(name = "page", required = false) Integer page, + @RequestParam(name = "limit", required = false) Integer limit, + @Auth(permit = {ADMIN}) Integer adminId + ) { + return ResponseEntity.ok(adminCallvanReportQueryService.getReports(onlyPending, page, limit)); + } + + @PostMapping("/{reportId}/process") + public ResponseEntity processCallvanReport( + @Parameter(in = PATH) @PathVariable Integer reportId, + @RequestBody @Valid AdminCallvanReportProcessRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminCallvanReportService.processReport(reportId, adminId, request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportProcessRequest.java b/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportProcessRequest.java new file mode 100644 index 0000000000..53cbe307ae --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportProcessRequest.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.admin.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCallvanReportProcessRequest( + @Schema(description = "신고 처리 유형", example = "WARNING", requiredMode = REQUIRED) + @NotNull(message = "process type is required") + CallvanReportProcessType processType +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportsResponse.java b/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportsResponse.java new file mode 100644 index 0000000000..d9f4a6b7db --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/dto/AdminCallvanReportsResponse.java @@ -0,0 +1,182 @@ +package in.koreatech.koin.admin.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.CallvanReportAttachment; +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; +import in.koreatech.koin.domain.callvan.model.CallvanReportReason; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminCallvanReportsResponse( + @Schema(description = "신고 접수 목록", requiredMode = REQUIRED) + List reports, + + @Schema(description = "전체 신고 수", example = "12", requiredMode = REQUIRED) + Long totalCount, + + @Schema(description = "현재 개수", example = "10", requiredMode = REQUIRED) + Integer currentCount, + + @Schema(description = "전체 페이지", example = "2", requiredMode = REQUIRED) + Integer totalPage, + + @Schema(description = "현재 페이지", example = "1", requiredMode = REQUIRED) + Integer currentPage +) { + + public static AdminCallvanReportsResponse from( + CallvanReportPagedResult pagedResult, + CallvanReportRelatedData relatedData) { + List responses = pagedResult.reports().stream() + .map(report -> AdminCallvanReportResponse.of(report, relatedData)) + .toList(); + + return new AdminCallvanReportsResponse( + responses, + pagedResult.totalCount(), + responses.size(), + pagedResult.totalPages(), + pagedResult.currentPage()); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record AdminCallvanReportResponse( + @Schema(description = "신고 ID", example = "1", requiredMode = REQUIRED) + Integer reportId, + + @Schema(description = "신고 상태", example = "PENDING", requiredMode = REQUIRED) + CallvanReportStatus reportStatus, + + @Schema(description = "피신고자 정보", requiredMode = REQUIRED) + ReportedUserResponse reportedUser, + + @Schema(description = "신고 접수 일자", example = "2026-03-07T13:20:00", requiredMode = REQUIRED) + LocalDateTime reportedAt, + + @Schema(description = "신고 사유", requiredMode = REQUIRED) + List reasons, + + @Schema(description = "어드민 처리 상태") + CallvanReportProcessType processType, + + @Schema(description = "임시 차단 종료일") + LocalDateTime restrictedUntil, + + @Schema(description = "신고 상황") + String description, + + @Schema(description = "첨부파일(이미지) url") + List attachmentUrls, + + @Schema(description = "피신고자에 대한 누적 신고 건수", example = "3", requiredMode = REQUIRED) + Integer accumulatedReportCount, + + @Schema(description = "누적 신고 정보", requiredMode = REQUIRED) + List accumulatedReports + ) { + + public static AdminCallvanReportResponse of(CallvanReport report, CallvanReportRelatedData relatedData) { + CallvanReportProcess process = relatedData.process(report.getId()); + + List accumulatedHistories = relatedData + .accumulatedReports(report.getReported().getId()).stream() + .filter(history -> !history.getId().equals(report.getId())) + .map(history -> AccumulatedReportHistoryResponse.of(history, relatedData)) + .toList(); + + return new AdminCallvanReportResponse( + report.getId(), + report.getStatus(), + ReportedUserResponse.from(report), + report.getCreatedAt(), + relatedData.reasons(report.getId()).stream().map(ReasonResponse::from).toList(), + process != null ? process.getProcessType() : null, + process != null ? process.getRestrictedUntil() : null, + report.getDescription(), + relatedData.attachments(report.getId()).stream() + .map(CallvanReportAttachment::getUrl).toList(), + accumulatedHistories.size() + 1, + accumulatedHistories + ); + } + } + + @JsonNaming(SnakeCaseStrategy.class) + public record ReportedUserResponse( + @Schema(description = "id", example = "23", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "사용자 이름", example = "홍길동") + String name, + + @Schema(description = "사용자 닉네임", example = "코리") + String nickname + ) { + + public static ReportedUserResponse from(CallvanReport report) { + return new ReportedUserResponse( + report.getReported().getId(), + report.getReported().getName(), + report.getReported().getNickname()); + } + } + + @JsonNaming(SnakeCaseStrategy.class) + public record ReasonResponse( + @Schema(description = "신고 사유", example = "NON_PAYMENT", requiredMode = REQUIRED) + CallvanReportReasonCode reasonCode, + + @Schema(description = "신고 사유(기타) 출력") + String customText + ) { + + public static ReasonResponse from(CallvanReportReason reason) { + return new ReasonResponse(reason.getReasonCode(), reason.getCustomText()); + } + } + + @JsonNaming(SnakeCaseStrategy.class) + public record AccumulatedReportHistoryResponse( + @Schema(description = "Id", example = "1", requiredMode = REQUIRED) + Integer reportId, + + @Schema(description = "신고 접수 일자", example = "2026-03-07T13:20:00", requiredMode = REQUIRED) + LocalDateTime reportedAt, + + @Schema(description = "신고 처리 상태", example = "CONFIRMED", requiredMode = REQUIRED) + CallvanReportStatus reportStatus, + + @Schema(description = "어드민 처리 유형") + CallvanReportProcessType processType, + + @Schema(description = "임시 차단 종료일") + LocalDateTime restrictedUntil, + + @Schema(description = "신고 사유", requiredMode = REQUIRED) + List reasons + ) { + + public static AccumulatedReportHistoryResponse of(CallvanReport report, CallvanReportRelatedData relatedData) { + CallvanReportProcess process = relatedData.process(report.getId()); + + return new AccumulatedReportHistoryResponse( + report.getId(), + report.getCreatedAt(), + report.getStatus(), + process != null ? process.getProcessType() : null, + process != null ? process.getRestrictedUntil() : null, + relatedData.reasons(report.getId()).stream().map(ReasonResponse::from).toList()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportPagedResult.java b/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportPagedResult.java new file mode 100644 index 0000000000..140fd68e89 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportPagedResult.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.admin.callvan.dto; + +import java.util.List; + +import in.koreatech.koin.domain.callvan.model.CallvanReport; + +public record CallvanReportPagedResult( + List reports, + long totalCount, + int totalPages, + int currentPage +) { +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportRelatedData.java b/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportRelatedData.java new file mode 100644 index 0000000000..6392d10b84 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/dto/CallvanReportRelatedData.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.admin.callvan.dto; + +import java.util.List; +import java.util.Map; + +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.CallvanReportAttachment; +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; +import in.koreatech.koin.domain.callvan.model.CallvanReportReason; + +public record CallvanReportRelatedData( + Map> reasonsByReportId, + Map> attachmentsByReportId, + Map processByReportId, + Map> accumulatedReportsByUserId +) { + + public List reasons(Integer reportId) { + return reasonsByReportId.getOrDefault(reportId, List.of()); + } + + public List attachments(Integer reportId) { + return attachmentsByReportId.getOrDefault(reportId, List.of()); + } + + public CallvanReportProcess process(Integer reportId) { + return processByReportId.get(reportId); + } + + public List accumulatedReports(Integer reportedUserId) { + return accumulatedReportsByUserId.getOrDefault(reportedUserId, List.of()); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportQueryService.java b/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportQueryService.java new file mode 100644 index 0000000000..e843188a64 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportQueryService.java @@ -0,0 +1,157 @@ +package in.koreatech.koin.admin.callvan.service; + +import static in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus.*; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportsResponse; +import in.koreatech.koin.admin.callvan.dto.CallvanReportPagedResult; +import in.koreatech.koin.admin.callvan.dto.CallvanReportRelatedData; +import in.koreatech.koin.common.model.Criteria; +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.CallvanReportAttachment; +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; +import in.koreatech.koin.domain.callvan.model.CallvanReportReason; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import in.koreatech.koin.domain.callvan.repository.CallvanReportAttachmentRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportProcessRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportReasonRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminCallvanReportQueryService { + + private static final List ADMIN_VISIBLE_STATUSES = List.of(PENDING, CONFIRMED, REJECTED); + private final CallvanReportRepository callvanReportRepository; + private final CallvanReportProcessRepository callvanReportProcessRepository; + private final CallvanReportReasonRepository callvanReportReasonRepository; + private final CallvanReportAttachmentRepository callvanReportAttachmentRepository; + + @Transactional(readOnly = true) + public AdminCallvanReportsResponse getReports(Boolean onlyPending, Integer page, Integer limit) { + List statuses = + onlyPending ? List.of(PENDING) : ADMIN_VISIBLE_STATUSES; + CallvanReportPagedResult pagedResult = getPagedReports(statuses, page, limit); + CallvanReportRelatedData relatedData = loadRelatedData(pagedResult); + return AdminCallvanReportsResponse.from(pagedResult, relatedData); + } + + private CallvanReportPagedResult getPagedReports(List statuses, Integer page, Integer limit) { + int total = Math.toIntExact(callvanReportRepository.countByStatusInAndIsDeletedFalse(statuses)); + Criteria criteria = Criteria.of(page, limit, total); + PageRequest pageable = PageRequest.of( + criteria.getPage(), + criteria.getLimit(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + ); + + Page pagedReports = callvanReportRepository.findAllByStatusInAndIsDeletedFalse( + statuses, pageable + ); + + return new CallvanReportPagedResult( + pagedReports.getContent(), pagedReports.getTotalElements(), + pagedReports.getTotalPages(), criteria.getPage() + 1 + ); + } + + private CallvanReportRelatedData loadRelatedData(CallvanReportPagedResult pagedResult) { + List currentReports = pagedResult.reports(); + List currentReportIds = currentReports.stream() + .map(CallvanReport::getId) + .toList(); + + List accumulatedReports = fetchAccumulatedReports(currentReports); + + List allReportIds = mergeDistinctIds(currentReportIds, accumulatedReports); + + return new CallvanReportRelatedData( + loadReasonsMap(allReportIds), + loadAttachmentsMap(currentReportIds), + loadProcessMap(allReportIds), + groupByReportedUserId(accumulatedReports)); + } + + private List fetchAccumulatedReports(List currentReports) { + List reportedUserIds = currentReports.stream() + .map(report -> report.getReported().getId()) + .distinct() + .toList(); + + if (reportedUserIds.isEmpty()) { + return List.of(); + } + + return callvanReportRepository.findAllByReportedIdInAndStatusInAndIsDeletedFalseOrderByCreatedAtDesc( + reportedUserIds, ADMIN_VISIBLE_STATUSES); + } + + private List mergeDistinctIds(List currentIds, List accumulatedReports) { + return Stream.concat( + currentIds.stream(), + accumulatedReports.stream().map(CallvanReport::getId)) + .distinct() + .toList(); + } + + private Map> loadReasonsMap(List reportIds) { + if (reportIds.isEmpty()) { + return Map.of(); + } + return groupByReportId( + callvanReportReasonRepository.findAllByReportIdIn(reportIds), + CallvanReportReason::getReport); + } + + private Map> loadAttachmentsMap(List reportIds) { + if (reportIds.isEmpty()) { + return Map.of(); + } + return groupByReportId( + callvanReportAttachmentRepository.findAllByReportIdIn(reportIds), + CallvanReportAttachment::getReport); + } + + private Map loadProcessMap(List reportIds) { + if (reportIds.isEmpty()) { + return Map.of(); + } + return callvanReportProcessRepository.findAllByReportIdIn(reportIds).stream() + .collect(Collectors.toMap( + process -> process.getReport().getId(), + Function.identity(), + (left, right) -> left)); + } + + private Map> groupByReportedUserId(List reports) { + return reports.stream() + .collect(Collectors.groupingBy( + report -> report.getReported().getId(), + LinkedHashMap::new, + Collectors.toList())); + } + + private static Map> groupByReportId( + Collection values, + Function reportExtractor) { + return values.stream() + .collect(Collectors.groupingBy( + value -> reportExtractor.apply(value).getId(), + LinkedHashMap::new, + Collectors.toList())); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportService.java b/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportService.java new file mode 100644 index 0000000000..35db6384eb --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/callvan/service/AdminCallvanReportService.java @@ -0,0 +1,65 @@ +package in.koreatech.koin.admin.callvan.service; + +import static in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus.PENDING; + +import java.time.LocalDateTime; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.callvan.dto.AdminCallvanReportProcessRequest; +import in.koreatech.koin.domain.callvan.event.CallvanReportSanctionEvent; +import in.koreatech.koin.domain.callvan.model.CallvanReport; +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; +import in.koreatech.koin.domain.callvan.repository.CallvanReportProcessRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportRepository; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCallvanReportService { + + private final CallvanReportRepository callvanReportRepository; + private final CallvanReportProcessRepository callvanReportProcessRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void processReport(Integer reportId, Integer adminId, AdminCallvanReportProcessRequest request) { + CallvanReport report = callvanReportRepository.getById(reportId); + validateProcessable(report); + + User adminUser = userRepository.getById(adminId); + LocalDateTime processedAt = LocalDateTime.now(); + + CallvanReportProcess process = CallvanReportProcess.create( + report, adminUser, request.processType(), request.processType().calculateRestrictedUntil(processedAt)); + callvanReportProcessRepository.save(process); + + if (request.processType().isRejected()) { + report.reject(adminUser); + return; + } + + report.confirm(adminUser); + + if (request.processType().isSanction()) { + eventPublisher.publishEvent( + new CallvanReportSanctionEvent(report.getReported().getId(), report.getPost().getId(), + request.processType())); + } + } + + private void validateProcessable(CallvanReport report) { + if (report.getStatus() != PENDING + || callvanReportProcessRepository.existsByReportIdAndIsDeletedFalse(report.getId())) { + throw CustomException.of(ApiResponseCode.CALLVAN_REPORT_ALREADY_PROCESSED); + } + } +} diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java index b472d2a794..7dafab30f5 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java @@ -1,11 +1,16 @@ package in.koreatech.koin.common.event; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; public record ArticleKeywordEvent( Integer articleId, Integer authorId, - ArticleKeyword keyword + Map matchedKeywordByUserId ) { + public ArticleKeywordEvent { + matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java index 4056d0a0b1..19b5428872 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java @@ -45,7 +45,8 @@ public interface CallvanApi { CREATED, NOT_FOUND_USER, INVALID_REQUEST_BODY, - INVALID_CUSTOM_LOCATION_NAME + INVALID_CUSTOM_LOCATION_NAME, + FORBIDDEN_CALLVAN_RESTRICTED_USER }) @Operation(summary = "콜밴 게시글 생성", description = """ ### 콜밴 게시글 생성 API @@ -120,6 +121,24 @@ ResponseEntity getCallvanPosts( @UserId Integer userId ); + @ApiResponseCodes({ + OK, + NOT_FOUND_ARTICLE + }) + @Operation(summary = "콜밴 게시글 요약 정보 조회", description = """ + ### 콜밴 게시글 요약 정보 조회 API + 목록 갱신을 위해 콜밴 게시글 목록 조회에서 출력되는 각 게시글을 단건으로 조회합니다. + + #### 비즈니스 로직 + 1. 존재하지 않는 게시글(`NOT_FOUND_ARTICLE`)이면 예외가 발생합니다. + 2. 로그인된 사용자의 경우, 해당 콜벤 게시글에 합류한 상태면 `isJoined` 필드가 true로 표시됩니다. + """) + @GetMapping("/posts/{postId}/summary") + ResponseEntity getCallvanPostSummary( + @PathVariable Integer postId, + @UserId Integer userId + ); + @ApiResponseCodes({ OK, NOT_FOUND_ARTICLE, @@ -149,7 +168,8 @@ ResponseEntity getCallvanPostDetail( NOT_FOUND_ARTICLE, CALLVAN_POST_NOT_RECRUITING, CALLVAN_POST_FULL, - CALLVAN_ALREADY_JOINED + CALLVAN_ALREADY_JOINED, + FORBIDDEN_CALLVAN_RESTRICTED_USER }) @Operation(summary = "콜밴 게시글 참여", description = """ ### 콜밴 게시글 참여 API @@ -294,6 +314,9 @@ ResponseEntity leaveCallvanPost( - `reasons`: 신고 사유 목록 (1개 이상) - `reason_code`: `NO_SHOW`, `NON_PAYMENT`, `PROFANITY`, `OTHER` - `custom_text`: `OTHER`일 때만 입력 가능. `OTHER` 선택 시 `custom_text`는 필수입니다 + - `attachments`: 첨부 사항 + - `attachment_type`: `IMAGE` + - `url`: 업로드된 이미지 s3 링크 #### 비즈니스 로직 1. 신고자와 피신고자가 동일하면 실패합니다. (`CALLVAN_REPORT_SELF`) @@ -364,19 +387,19 @@ ResponseEntity sendMessage( 로그인한 사용자의 알림 목록을 최신순으로 조회합니다. ### 알림 타입별 데이터 구조 - | 필드명 | RECRUITMENT_COMPLETE(인원 모집 완료) | NEW_MESSAGE(신규 채팅 도착) | PARTICIPANT_JOINED(신규 인원 참여) | DEPARTURE_UPCOMING(출발 30분 전) | - | :--- | :--- | :--- | :--- | :--- | - | type | RECRUITMENT_COMPLETE | NEW_MESSAGE | PARTICIPANT_JOINED | DEPARTURE_UPCOMING | - | message_preview | "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요" | 신규 채팅 메시지 내용 | null | null | - | sender_nickname | null | 발신자 닉네임 | null | null | - | joined_member_nickname | null | null | 참여자 닉네임 | null | - | post_id | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | - | departure | 출발지 | 출발지 | 출발지 | 출발지 | - | arrival | 도착지 | 도착지 | 도착지 | 도착지 | - | departure_date | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | - | departure_time | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | - | current_participants | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | - | max_participants | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | + | 필드명 | RECRUITMENT_COMPLETE(인원 모집 완료) | NEW_MESSAGE(신규 채팅 도착) | PARTICIPANT_JOINED(신규 인원 참여) | DEPARTURE_UPCOMING(출발 30분 전) | REPORT_WARNING(사용자 1차 제재(경고)) | REPORT_RESTRICTION_14_DAYS(14일 이용 제한) | REPORT_PERMANENT_RESTRICTION(영구 이용 제한) | + | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | + | type | RECRUITMENT_COMPLETE | NEW_MESSAGE | PARTICIPANT_JOINED | DEPARTURE_UPCOMING | REPORT_WARNING | REPORT_RESTRICTION_14_DAYS | REPORT_PERMANENT_RESTRICTION | + | message_preview | "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요" | 신규 채팅 메시지 내용 | null | null | "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후..." | "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 14일간..." | "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 콜벤 기능 이용이 영구적으로..." | + | sender_nickname | null | 발신자 닉네임 | null | null | null | null | null | + | joined_member_nickname | null | null | 참여자 닉네임 | null | null | null | null | + | post_id | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | 게시글 ID | + | departure | 출발지 | 출발지 | 출발지 | 출발지 | 출발지 | 출발지 | 출발지 | + | arrival | 도착지 | 도착지 | 도착지 | 도착지 | 도착지 | 도착지 | 도착지 | + | departure_date | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | 출발 날짜 | + | departure_time | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | 출발 시간 | + | current_participants | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | 현재 인원 | + | max_participants | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | 최대 인원 | """) @GetMapping("/notifications") ResponseEntity> getNotifications( diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java index 134247dcc7..241b1cc462 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java @@ -10,13 +10,13 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest; import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse.CallvanPostResponse; import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; @@ -83,6 +83,15 @@ public ResponseEntity getCallvanPosts( return ResponseEntity.ok().body(response); } + @GetMapping("/posts/{postId}/summary") + public ResponseEntity getCallvanPostSummary( + @PathVariable Integer postId, + @UserId Integer userId + ) { + CallvanPostResponse response = callvanPostQueryService.getCallvanPostSummary(postId, userId); + return ResponseEntity.ok(response); + } + @GetMapping("/posts/{postId}") public ResponseEntity getCallvanPostDetail( @PathVariable Integer postId, diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java index 404fcedaa0..44a98bd20c 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanPostDetailResponse.java @@ -4,12 +4,11 @@ import java.time.LocalTime; import java.util.List; import java.util.Objects; +import java.util.Set; -import org.apache.commons.lang3.RandomStringUtils; import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -52,7 +51,7 @@ public record CallvanPostDetailResponse( List participants ) { - public static CallvanPostDetailResponse from(CallvanPost post, Integer userId) { + public static CallvanPostDetailResponse from(CallvanPost post, Integer userId, Set reportedUserIds) { String departureName = post.getDepartureType().getName(); if (StringUtils.hasText(post.getDepartureCustomName())) { departureName = post.getDepartureCustomName(); @@ -75,9 +74,8 @@ public static CallvanPostDetailResponse from(CallvanPost post, Integer userId) { post.getStatus().name(), post.getParticipants().stream() .filter(p -> !p.getIsDeleted()) - .map(it -> CallvanParticipantResponse.from(it, userId)) - .toList() - ); + .map(it -> CallvanParticipantResponse.from(it, userId, reportedUserIds)) + .toList()); } @JsonNaming(SnakeCaseStrategy.class) @@ -89,16 +87,22 @@ public record CallvanParticipantResponse( String nickname, @Schema(description = "본인 여부", example = "false") - Boolean is_me + Boolean isMe, + + @Schema(description = "신고 접수 여부", example = "false") + Boolean isReported ) { - public static CallvanParticipantResponse from(CallvanParticipant participant, Integer userId) { - String nickname = participant.getMember().getDisplayNickname(); + public static CallvanParticipantResponse from( + CallvanParticipant participant, Integer userId, Set reportedUserIds + ) { Integer participantId = participant.getMember().getId(); + String nickname = participant.getMember().getDisplayNickname(); return new CallvanParticipantResponse( - participant.getMember().getId(), + participantId, nickname, - Objects.equals(userId, participantId) + Objects.equals(userId, participantId), + reportedUserIds.contains(participantId) ); } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java index 767da45c2c..7b27c5481a 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanUserReportCreateRequest.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportAttachmentType; import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -23,7 +24,13 @@ public record CallvanUserReportCreateRequest( @Schema(description = "신고 사유 목록", requiredMode = REQUIRED) @NotEmpty(message = "신고 사유는 1개 이상 선택해야 합니다.") @Valid - List reasons + List reasons, + + @Schema(description = "신고 상세 상황 설명") + String description, + + @Schema(description = "신고 증빙 자료 첨부") + List attachments ) { @JsonNaming(SnakeCaseStrategy.class) @@ -37,4 +44,16 @@ public record CallvanUserReportReasonRequest( String customText ) { } + + @JsonNaming(SnakeCaseStrategy.class) + public record CallvanUserReportAttachmentRequest( + @Schema(description = "첨부파일 유형", example = "IMAGE", requiredMode = REQUIRED) + @NotNull(message = "첨부파일 유형은 필수입니다.") + CallvanReportAttachmentType attachmentType, + + @Schema(description = "첨부파일 S3 url", requiredMode = REQUIRED) + @Size(max = 500, message = "첨부파일 S3 url은 500자 이하여야 합니다.") + String url + ) { + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java index de4fb791b1..151ab70410 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanNewMessageEvent.java @@ -1,10 +1,13 @@ package in.koreatech.koin.domain.callvan.event; +import in.koreatech.koin.domain.callvan.model.enums.CallvanMessageType; + public record CallvanNewMessageEvent( Integer postId, String senderNickname, Integer sendUserId, - String content + String content, + CallvanMessageType messageType ) { } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanReportSanctionEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanReportSanctionEvent.java new file mode 100644 index 0000000000..6fdbe0a10a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanReportSanctionEvent.java @@ -0,0 +1,10 @@ +package in.koreatech.koin.domain.callvan.event; + +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; + +public record CallvanReportSanctionEvent( + Integer reportedUserId, + Integer callvanPostId, + CallvanReportProcessType processType +) { +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java index 9ae0672650..582e357063 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPost.java @@ -93,6 +93,9 @@ public class CallvanPost extends BaseEntity { @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) private List participants = new ArrayList<>(); + @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) + private List reports = new ArrayList<>(); + @Builder private CallvanPost( User author, diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java index 4bcf4a9d21..5f25270df5 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReport.java @@ -12,6 +12,7 @@ import org.springframework.util.StringUtils; import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportAttachmentType; import in.koreatech.koin.domain.callvan.model.enums.CallvanReportReasonCode; import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; import in.koreatech.koin.domain.user.model.User; @@ -83,6 +84,9 @@ public class CallvanReport extends BaseEntity { @OneToMany(mappedBy = "report", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) private List reasons = new ArrayList<>(); + @OneToMany(mappedBy = "report", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) + private List attachments = new ArrayList<>(); + @Builder private CallvanReport( CallvanPost post, @@ -111,12 +115,14 @@ private CallvanReport( public static CallvanReport create( CallvanPost post, User reporter, - User reported + User reported, + String description ) { return CallvanReport.builder() .post(post) .reporter(reporter) .reported(reported) + .description(description) .status(CallvanReportStatus.PENDING) .build(); } @@ -142,10 +148,42 @@ public void registerReasons(List reasonCommand } } + public void registerAttachments(List attachmentCommands) { + if (attachmentCommands == null || attachmentCommands.isEmpty()) { + return; + } + + if (attachmentCommands.size() > 10) + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + + for (CallvanReportAttachmentCreateCommand attachmentCommand : attachmentCommands) { + if (attachmentCommand == null) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + + this.attachments.add( + CallvanReportAttachment.create(this, attachmentCommand.attachmentType, attachmentCommand.url) + ); + } + } + public void cancel() { this.status = CallvanReportStatus.CANCELED; } + public void confirm(User reviewer) { + this.status = CallvanReportStatus.CONFIRMED; + this.reviewer = reviewer; + this.reviewedAt = LocalDateTime.now(); + this.confirmedAt = this.reviewedAt; + } + + public void reject(User reviewer) { + this.status = CallvanReportStatus.REJECTED; + this.reviewer = reviewer; + this.reviewedAt = LocalDateTime.now(); + } + private static String normalizeText(String text) { if (!StringUtils.hasText(text)) { return null; @@ -159,4 +197,10 @@ public record CallvanReportReasonCreateCommand( String customText ) { } + + public record CallvanReportAttachmentCreateCommand( + CallvanReportAttachmentType attachmentType, + String url + ) { + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportAttachment.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportAttachment.java new file mode 100644 index 0000000000..6dbef0a87e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportAttachment.java @@ -0,0 +1,78 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import org.hibernate.annotations.Where; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportAttachmentType; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_report_attachment") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanReportAttachment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", nullable = false) + private CallvanReport report; + + @Enumerated(EnumType.STRING) + @Column(name = "attachment_type", nullable = false, length = 30) + private CallvanReportAttachmentType attachmentType; + + @Column(name = "url", length = 500) + private String url; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + public CallvanReportAttachment(CallvanReport report, CallvanReportAttachmentType attachmentType, String url, + Boolean isDeleted) { + this.report = report; + this.attachmentType = attachmentType; + this.url = url; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public static CallvanReportAttachment create( + CallvanReport report, CallvanReportAttachmentType attachmentType, String url + ) { + validateAttachment(attachmentType, url); + + return CallvanReportAttachment.builder() + .report(report) + .attachmentType(attachmentType) + .url(url.trim()) + .build(); + } + + private static void validateAttachment(CallvanReportAttachmentType attachmentType, String url) { + if (attachmentType == null || !StringUtils.hasText(url) || url.trim().length() > 500) { + throw CustomException.of(ApiResponseCode.INVALID_REQUEST_BODY); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportProcess.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportProcess.java new file mode 100644 index 0000000000..3967104699 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanReportProcess.java @@ -0,0 +1,84 @@ +package in.koreatech.koin.domain.callvan.model; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.Where; + +import in.koreatech.koin.common.model.BaseEntity; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "callvan_report_process") +@Where(clause = "is_deleted=0") +@NoArgsConstructor(access = PROTECTED) +public class CallvanReportProcess extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", nullable = false) + private CallvanReport report; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processor_id", nullable = false) + private User processor; + + @Enumerated(EnumType.STRING) + @Column(name = "process_type", nullable = false, length = 50) + private CallvanReportProcessType processType; + + @Column(name = "restricted_until", columnDefinition = "DATETIME") + private LocalDateTime restrictedUntil; + + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Builder + private CallvanReportProcess( + CallvanReport report, + User processor, + CallvanReportProcessType processType, + LocalDateTime restrictedUntil, + Boolean isDeleted + ) { + this.report = report; + this.processor = processor; + this.processType = processType; + this.restrictedUntil = restrictedUntil; + this.isDeleted = isDeleted != null ? isDeleted : false; + } + + public static CallvanReportProcess create( + CallvanReport report, + User processor, + CallvanReportProcessType processType, + LocalDateTime restrictedUntil + ) { + return CallvanReportProcess.builder() + .report(report) + .processor(processor) + .processType(processType) + .restrictedUntil(restrictedUntil) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java index 1d448d2a3f..f29a718d9f 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanMessageType.java @@ -1,6 +1,27 @@ package in.koreatech.koin.domain.callvan.model.enums; public enum CallvanMessageType { - TEXT, - IMAGE + TEXT { + @Override + public String toNotificationContent(String senderNickname, String messageContent) { + return truncate(messageContent); + } + }, + IMAGE { + @Override + public String toNotificationContent(String senderNickname, String messageContent) { + return senderNickname + "님이 사진을 보냈습니다."; + } + }; + + private static final int MAX_LENGTH = 95; + + public abstract String toNotificationContent(String senderNickname, String messageContent); + + private static String truncate(String content) { + if (content == null || content.length() <= MAX_LENGTH) { + return content; + } + return content.substring(0, MAX_LENGTH) + "..."; + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java index 77d4289b96..6fd50458ab 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanNotificationType.java @@ -4,5 +4,8 @@ public enum CallvanNotificationType { RECRUITMENT_COMPLETE, NEW_MESSAGE, PARTICIPANT_JOINED, - DEPARTURE_UPCOMING + DEPARTURE_UPCOMING, + REPORT_WARNING, + REPORT_RESTRICTION_14_DAYS, + REPORT_PERMANENT_RESTRICTION } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportAttachmentType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportAttachmentType.java new file mode 100644 index 0000000000..f604dd44b4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportAttachmentType.java @@ -0,0 +1,5 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +public enum CallvanReportAttachmentType { + IMAGE +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportProcessType.java b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportProcessType.java new file mode 100644 index 0000000000..5bdf6fe44d --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/enums/CallvanReportProcessType.java @@ -0,0 +1,29 @@ +package in.koreatech.koin.domain.callvan.model.enums; + +import java.time.LocalDateTime; + +public enum CallvanReportProcessType { + WARNING, + TEMPORARY_RESTRICTION_14_DAYS, + PERMANENT_RESTRICTION, + REJECT; + + public boolean isRejected() { + return this == REJECT; + } + + public boolean isWarning() { + return this == WARNING; + } + + public boolean isSanction() { + return this == WARNING || this == TEMPORARY_RESTRICTION_14_DAYS || this == PERMANENT_RESTRICTION; + } + + public LocalDateTime calculateRestrictedUntil(LocalDateTime processedAt) { + if (this == TEMPORARY_RESTRICTION_14_DAYS) { + return processedAt.plusDays(14); + } + return null; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java index 3aec9c8d79..417d39d943 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanParticipantRepository.java @@ -19,5 +19,5 @@ public interface CallvanParticipantRepository extends Repository findAllByMemberIdAndPostIdIn(Integer memberId, List postIds); + List findAllByMemberIdAndPostIdInAndIsDeletedFalse(Integer memberId, List postIds); } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java index a1f15ff1fd..cbcb40f92d 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanPostRepository.java @@ -1,10 +1,8 @@ package in.koreatech.koin.domain.callvan.repository; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.callvan.model.CallvanPost; @@ -21,6 +19,11 @@ default CallvanPost getById(Integer postId) { return findById(postId).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_ARTICLE)); } - List findAllByDepartureDateAndDepartureTimeAndIsDeletedFalse(LocalDate departureDate, - LocalTime departureTime); + @EntityGraph(attributePaths = { "participants", "participants.member" }) + Optional findWithParticipantsById(Integer id); + + default CallvanPost getWithParticipantsById(Integer postId) { + return findWithParticipantsById(postId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_ARTICLE)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportAttachmentRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportAttachmentRepository.java new file mode 100644 index 0000000000..5e9964f91f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportAttachmentRepository.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.callvan.model.CallvanReportAttachment; + +public interface CallvanReportAttachmentRepository extends Repository { + + List findAllByReportIdIn(List reportIds); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java new file mode 100644 index 0000000000..74c4469ca0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; + +public interface CallvanReportProcessRepository extends Repository { + + CallvanReportProcess save(CallvanReportProcess process); + + List findAllByReportIdIn(List reportIds); + + boolean existsByReportIdAndIsDeletedFalse(Integer reportId); + + @Query(""" + SELECT CASE WHEN COUNT(process) > 0 THEN true ELSE false END + FROM CallvanReportProcess process + WHERE process.report.reported.id = :userId + AND process.isDeleted = false + AND ( + process.processType = in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType.PERMANENT_RESTRICTION + OR ( + process.processType = in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType.TEMPORARY_RESTRICTION_14_DAYS + AND process.restrictedUntil IS NOT NULL + AND process.restrictedUntil >= :now + ) + ) + """) + boolean existsActiveRestrictionByReportedUserId( + @Param("userId") Integer userId, + @Param("now") LocalDateTime now + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportReasonRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportReasonRepository.java new file mode 100644 index 0000000000..0c8d0ed8ee --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportReasonRepository.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.domain.callvan.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; +import in.koreatech.koin.domain.callvan.model.CallvanReportReason; + +public interface CallvanReportReasonRepository extends Repository { + + List findAllByReportIdIn(List reportIds); +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java index f3f3f345b9..70b7f9feb2 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportRepository.java @@ -1,20 +1,65 @@ package in.koreatech.koin.domain.callvan.repository; import java.util.List; +import java.util.Optional; +import java.util.Set; +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.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.callvan.model.CallvanReport; import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; public interface CallvanReportRepository extends Repository { CallvanReport save(CallvanReport callvanReport); + Optional findById(Integer id); + + default CallvanReport getById(Integer reportId) { + return findById(reportId).orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CALLVAN_REPORT)); + } + boolean existsByPostIdAndReporterIdAndReportedIdAndStatusInAndIsDeletedFalse( Integer postId, Integer reporterId, Integer reportedId, List statuses ); + + @EntityGraph(attributePaths = "reported") + Page findAllByStatusInAndIsDeletedFalse(List statuses, Pageable pageable); + + long countByStatusInAndIsDeletedFalse(List statuses); + + @EntityGraph(attributePaths = "reported") + List findAllByReportedIdInAndStatusInAndIsDeletedFalseOrderByCreatedAtDesc( + List reportedIds, + List statuses + ); + + @Query("SELECT DISTINCT r.reported.id FROM CallvanReport r " + + "WHERE r.post.id = :postId " + + "AND r.status IN :statuses " + + "AND r.isDeleted = false") + Set findReportedUserIdsByPostIdAndStatusIn( + @Param("postId") Integer postId, + @Param("statuses") List statuses + ); + + @Query("SELECT DISTINCT r.reported.id FROM CallvanReport r " + + "WHERE r.post.id = :postId " + + "AND r.reporter.id = :reporterId " + + "AND r.status IN :statuses " + + "AND r.isDeleted = false") + Set findReportedUserIdsByPostIdAndReporterIdAndStatusIn( + @Param("postId") Integer postId, + @Param("reporterId") Integer reporterId, + @Param("statuses") List statuses); } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java index 1c9b1fb142..add7043415 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanChatService.java @@ -68,6 +68,8 @@ public void sendMessage(Integer postId, Integer userId, CallvanChatMessageReques callvanChatMessageRepository.save(message); eventPublisher.publishEvent( - new CallvanNewMessageEvent(callvanPost.getId(), message.getSenderNickname(), sender.getId(), message.getContent())); + new CallvanNewMessageEvent(callvanPost.getId(), message.getSenderNickname(), sender.getId(), + message.getContent(), message.getMessageType()) + ); } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java index 02f50a47e4..28952de81d 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationEventListener.java @@ -5,15 +5,10 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import in.koreatech.koin.domain.callvan.event.CallvanReportSanctionEvent; import in.koreatech.koin.domain.callvan.event.CallvanNewMessageEvent; import in.koreatech.koin.domain.callvan.event.CallvanParticipantJoinedEvent; import in.koreatech.koin.domain.callvan.event.CallvanRecruitmentCompletedEvent; -import in.koreatech.koin.domain.callvan.model.CallvanNotification; -import in.koreatech.koin.domain.callvan.model.CallvanParticipant; -import in.koreatech.koin.domain.callvan.model.CallvanPost; -import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; -import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; -import in.koreatech.koin.domain.user.model.User; import lombok.RequiredArgsConstructor; @Component @@ -32,7 +27,7 @@ public void onRecruitmentCompleted(CallvanRecruitmentCompletedEvent event) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onNewMessage(CallvanNewMessageEvent event) { callvanNotificationService.notifyNewMessageReceived(event.postId(), event.sendUserId(), event.senderNickname(), - event.content()); + event.content(), event.messageType()); } @Async @@ -41,4 +36,11 @@ public void onParticipantJoined(CallvanParticipantJoinedEvent event) { callvanNotificationService.notifyParticipantJoined(event.callvanPostId(), event.joinUserId(), event.joinUserNickname()); } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReportSanctionIssued(CallvanReportSanctionEvent event) { + callvanNotificationService.notifyReportSanction( + event.reportedUserId(), event.callvanPostId(), event.processType()); + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java index 25efba691d..87d8a19c51 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java @@ -8,10 +8,15 @@ import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; import in.koreatech.koin.domain.callvan.model.CallvanNotification; import in.koreatech.koin.domain.callvan.model.CallvanPost; +import in.koreatech.koin.domain.callvan.model.enums.CallvanMessageType; import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; @Service @@ -19,8 +24,12 @@ @Transactional(readOnly = true) public class CallvanNotificationService { + private static final String CALLVAN_WARNING_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 주의 안내가 전달되었습니다. 이후 동일한 문제가 반복될 경우 콜벤 기능 이용이 제한될 수 있습니다."; + private static final String CALLVAN_RESTRICTION_14_DAYS_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 14일간 콜벤 기능 이용이 제한되었습니다."; + private static final String CALLVAN_PERMANENT_RESTRICTION_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 콜벤 기능 이용이 영구적으로 제한되었습니다."; private final CallvanPostRepository callvanPostRepository; private final CallvanNotificationRepository callvanNotificationRepository; + private final UserRepository userRepository; public List getNotifications(Integer userId) { return callvanNotificationRepository.findAllByRecipientIdOrderByCreatedAtDesc(userId).stream() @@ -80,14 +89,17 @@ public void notifyParticipantJoined(Integer postId, Integer joinUserId, String j } @Transactional - public void notifyNewMessageReceived(Integer postId, Integer senderId, String senderNickname, String messageContent) { + public void notifyNewMessageReceived(Integer postId, Integer senderId, String senderNickname, String messageContent, + CallvanMessageType messageType + ) { CallvanPost post = callvanPostRepository.getById(postId); + String notificationContent = messageType.toNotificationContent(senderNickname, messageContent); List notifications = post.getParticipants().stream() .filter(p -> !p.getIsDeleted()) .filter(p -> !p.getMember().getId().equals(senderId)) .map(p -> buildNotification(p.getMember(), CallvanNotificationType.NEW_MESSAGE, post, - senderNickname, messageContent, null)) + senderNickname, notificationContent, null)) .toList(); if (!notifications.isEmpty()) { @@ -95,10 +107,33 @@ public void notifyNewMessageReceived(Integer postId, Integer senderId, String se } } + @Transactional + public void notifyReportSanction(Integer recipientId, Integer postId, CallvanReportProcessType processType) { + User recipient = userRepository.getById(recipientId); + CallvanPost post = callvanPostRepository.getById(postId); + + CallvanNotificationType notificationType = switch (processType) { + case WARNING -> CallvanNotificationType.REPORT_WARNING; + case TEMPORARY_RESTRICTION_14_DAYS -> CallvanNotificationType.REPORT_RESTRICTION_14_DAYS; + case PERMANENT_RESTRICTION -> CallvanNotificationType.REPORT_PERMANENT_RESTRICTION; + default -> throw CustomException.of(ApiResponseCode.ILLEGAL_ARGUMENT); + }; + + String message = switch (processType) { + case WARNING -> CALLVAN_WARNING_MESSAGE; + case TEMPORARY_RESTRICTION_14_DAYS -> CALLVAN_RESTRICTION_14_DAYS_MESSAGE; + case PERMANENT_RESTRICTION -> CALLVAN_PERMANENT_RESTRICTION_MESSAGE; + default -> throw CustomException.of(ApiResponseCode.ILLEGAL_ARGUMENT); + }; + + CallvanNotification callvanNotification = buildNotification( + recipient, notificationType, post, null, message, null); + + callvanNotificationRepository.save(callvanNotification); + } private CallvanNotification buildNotification(User recipient, CallvanNotificationType type, CallvanPost post, - String senderNickname, String messagePreview, String joinedMemberNickname - ) { + String senderNickname, String messagePreview, String joinedMemberNickname) { return CallvanNotification.builder() .recipient(recipient) .notificationType(type) @@ -117,5 +152,4 @@ private CallvanNotification buildNotification(User recipient, CallvanNotificatio .joinedMemberNickname(joinedMemberNickname) .build(); } - } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java index 01eee83ba1..a2a509c46d 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostCreateService.java @@ -29,9 +29,11 @@ public class CallvanPostCreateService { private final CallvanChatRoomRepository callvanChatRoomRepository; private final UserRepository userRepository; private final CallvanNotificationScheduler callvanNotificationScheduler; + private final CallvanRestrictionService callvanRestrictionService; @Transactional public CallvanPostCreateResponse createCallvanPost(CallvanPostCreateRequest request, Integer userId) { + callvanRestrictionService.validateNotRestricted(userId); User user = userRepository.getById(userId); validateLocation(request.departureType(), request.departureCustomName()); diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java index 52454b7aaf..cd5c429963 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostJoinService.java @@ -31,10 +31,12 @@ public class CallvanPostJoinService { private final UserRepository userRepository; private final EntityManager entityManager; private final ApplicationEventPublisher eventPublisher; + private final CallvanRestrictionService callvanRestrictionService; @Transactional @ConcurrencyGuard(lockName = "callvanJoin") public void join(Integer postId, Integer userId) { + callvanRestrictionService.validateNotRestricted(userId); CallvanPost callvanPost = callvanPostRepository.getById(postId); User user = userRepository.getById(userId); diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java index bccddcd5f7..2c9bcd395d 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPostQueryService.java @@ -1,6 +1,7 @@ package in.koreatech.koin.domain.callvan.service; import java.util.List; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +12,8 @@ import in.koreatech.koin.domain.callvan.model.CallvanParticipant; import in.koreatech.koin.domain.callvan.model.CallvanPost; import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportStatus; +import in.koreatech.koin.domain.callvan.model.enums.CallvanRole; import in.koreatech.koin.domain.callvan.model.enums.CallvanStatus; import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; @@ -18,12 +21,12 @@ import in.koreatech.koin.domain.callvan.repository.CallvanParticipantRepository; import in.koreatech.koin.domain.callvan.repository.CallvanPostQueryRepository; import in.koreatech.koin.domain.callvan.repository.CallvanPostRepository; +import in.koreatech.koin.domain.callvan.repository.CallvanReportRepository; import in.koreatech.koin.global.code.ApiResponseCode; import in.koreatech.koin.global.exception.CustomException; import lombok.RequiredArgsConstructor; import java.util.Collections; -import java.util.Set; import java.util.stream.Collectors; @Service @@ -31,9 +34,15 @@ @Transactional(readOnly = true) public class CallvanPostQueryService { + private static final List ACTIVE_REPORT_STATUSES = List.of( + CallvanReportStatus.PENDING, + CallvanReportStatus.UNDER_REVIEW, + CallvanReportStatus.CONFIRMED); + private final CallvanPostQueryRepository callvanPostQueryRepository; private final CallvanParticipantRepository callvanParticipantRepository; private final CallvanPostRepository callvanPostRepository; + private final CallvanReportRepository callvanReportRepository; public CallvanPostSearchResponse getCallvanPosts( CallvanAuthorFilter authorFilter, @@ -69,8 +78,8 @@ public CallvanPostSearchResponse getCallvanPosts( List postIds = posts.stream() .map(CallvanPost::getId) .toList(); - List participants = callvanParticipantRepository.findAllByMemberIdAndPostIdIn(userId, - postIds); + List participants = callvanParticipantRepository.findAllByMemberIdAndPostIdInAndIsDeletedFalse( + userId, postIds); joinedPostIds = participants.stream() .map(participant -> participant.getPost().getId()) .collect(Collectors.toSet()); @@ -89,8 +98,28 @@ public CallvanPostDetailResponse getCallvanPostDetail(Integer postId, Integer us if (!callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId)) { throw CustomException.of(ApiResponseCode.FORBIDDEN_PARTICIPANT); } - CallvanPost callvanPost = callvanPostRepository.getById(postId); + CallvanPost callvanPost = callvanPostRepository.getWithParticipantsById(postId); + + boolean isAuthor = callvanPost.getParticipants().stream() + .anyMatch(p -> p.getMember().getId().equals(userId) + && p.getRole() == CallvanRole.AUTHOR); - return CallvanPostDetailResponse.from(callvanPost, userId); + Set reportedUserIds; + if (isAuthor) { + reportedUserIds = callvanReportRepository.findReportedUserIdsByPostIdAndStatusIn( + postId, ACTIVE_REPORT_STATUSES); + } else { + reportedUserIds = callvanReportRepository.findReportedUserIdsByPostIdAndReporterIdAndStatusIn( + postId, userId, ACTIVE_REPORT_STATUSES); + } + + return CallvanPostDetailResponse.from(callvanPost, userId, reportedUserIds); + } + + public CallvanPostSearchResponse.CallvanPostResponse getCallvanPostSummary(Integer postId, Integer userId) { + CallvanPost callvanPost = callvanPostRepository.getById(postId); + boolean isJoined = + userId != null && callvanParticipantRepository.existsByPostIdAndMemberIdAndIsDeletedFalse(postId, userId); + return CallvanPostSearchResponse.CallvanPostResponse.from(callvanPost, isJoined, userId); } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java new file mode 100644 index 0000000000..6e65c43f39 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java @@ -0,0 +1,30 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.repository.CallvanReportProcessRepository; +import in.koreatech.koin.global.code.ApiResponseCode; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanRestrictionService { + + private final CallvanReportProcessRepository callvanReportProcessRepository; + + public void validateNotRestricted(Integer userId) { + boolean isRestricted = callvanReportProcessRepository.existsActiveRestrictionByReportedUserId( + userId, + LocalDateTime.now() + ); + + if (isRestricted) { + throw CustomException.of(ApiResponseCode.FORBIDDEN_CALLVAN_RESTRICTED_USER); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java index 69c16cab84..801f544f45 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanUserReportService.java @@ -55,7 +55,8 @@ public void reportUser( CallvanReport callvanReport = CallvanReport.create( callvanPost, reporter, - reported + reported, + request.description() ); callvanReport.registerReasons( @@ -67,6 +68,15 @@ public void reportUser( .toList() ); + callvanReport.registerAttachments( + request.attachments() == null ? List.of() : request.attachments().stream() + .map(attachment -> new CallvanReport.CallvanReportAttachmentCreateCommand( + attachment.attachmentType(), + attachment.url() + )) + .toList() + ); + callvanReportRepository.save(callvanReport); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index da0d9ac0ba..daabf61dc3 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -28,6 +28,8 @@ public interface ArticleRepository extends Repository { Optional
findById(Integer articleId); + List
findAllByIdIn(List articleIds); + Page
findAll(Pageable pageable); Page
findAllByBoardIdNot(Integer boardId, PageRequest pageRequest); diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java index 611cdfab34..262f71fc79 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java @@ -34,4 +34,8 @@ public UserNotificationStatus(Integer userId, Integer notifiedArticleId) { this.userId = userId; this.notifiedArticleId = notifiedArticleId; } + + public void updateNotifiedArticleId(Integer notifiedArticleId) { + this.notifiedArticleId = notifiedArticleId; + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java index 48cc034a50..c1c00bc818 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java @@ -45,4 +45,6 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( @Param("articleKeywordId") Integer articleKeywordId, @Param("userId") Integer userId ); + + List findAllByArticleKeywordIdIn(List articleKeywordIds); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index a405128ef2..9ab80b0e63 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -1,9 +1,13 @@ package in.koreatech.koin.domain.community.keyword.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; @@ -14,4 +18,26 @@ public interface UserNotificationStatusRepository extends Repository findByUserId(Integer userId); boolean existsByNotifiedArticleIdAndUserId(Integer notifiedArticleId, Integer userId); + + @Query(""" + SELECT status.userId + FROM UserNotificationStatus status + WHERE status.notifiedArticleId = :notifiedArticleId + AND status.userId IN :userIds + """) + List findUserIdsByNotifiedArticleIdAndUserIdIn( + @Param("notifiedArticleId") Integer notifiedArticleId, + @Param("userIds") Collection userIds + ); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_notification_status (user_id, last_notified_article_id) + VALUES (:userId, :notifiedArticleId) + ON DUPLICATE KEY UPDATE last_notified_article_id = :notifiedArticleId + """, nativeQuery = true) + void upsertLastNotifiedArticleId( + @Param("userId") Integer userId, + @Param("notifiedArticleId") Integer notifiedArticleId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 0c083a2067..b8e79e4759 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -1,7 +1,6 @@ package in.koreatech.koin.domain.community.keyword.service; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -12,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.community.article.exception.ArticleNotFoundException; import in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -26,7 +26,6 @@ import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordSuggestCache; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; -import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; @@ -148,22 +147,30 @@ public ArticleKeywordsSuggestionResponse suggestKeywords() { } public void sendKeywordNotification(KeywordNotificationRequest request) { - List updateNotificationIds = request.updateNotification(); - - if (!updateNotificationIds.isEmpty()) { - List
articles = new ArrayList<>(); - - for (Integer id : updateNotificationIds) { - articles.add(articleRepository.getById(id)); - } + List updateNotificationIds = request.updateNotification().stream() + .distinct() + .toList(); - List keywordEvents = keywordExtractor.matchKeyword(articles, null); + if (updateNotificationIds.isEmpty()) { + return; + } - if (!keywordEvents.isEmpty()) { - for (ArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); + List
fetchedArticles = articleRepository.findAllByIdIn(updateNotificationIds); + var articleById = fetchedArticles.stream() + .collect(Collectors.toMap(Article::getId, article -> article)); + List
articles = updateNotificationIds.stream() + .map(articleId -> { + Article article = articleById.get(articleId); + if (article == null) { + throw ArticleNotFoundException.withDetail("articleId: " + articleId); } - } + return article; + }) + .toList(); + + List keywordEvents = keywordExtractor.matchKeyword(articles, null); + for (ArticleKeywordEvent event : keywordEvents) { + eventPublisher.publishEvent(event); } } @@ -201,6 +208,6 @@ public void fetchTopKeywordsFromLastWeek() { @Transactional public void createNotifiedArticleStatus(Integer userId, Integer articleId) { - userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId)); + userNotificationStatusRepository.upsertLastNotifiedArticleId(userId, articleId); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java index b90e80141f..2fce59b974 100644 --- a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java +++ b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java @@ -1,7 +1,10 @@ package in.koreatech.koin.domain.community.util; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -10,8 +13,10 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import lombok.RequiredArgsConstructor; @Service @@ -22,9 +27,10 @@ public class KeywordExtractor { private static final int KEYWORD_BATCH_SIZE = 100; private final ArticleKeywordRepository articleKeywordRepository; + private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; public List matchKeyword(List
articles, Integer authorId) { - List keywordEvents = new ArrayList<>(); + Map> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>(); int offset = 0; while (true) { @@ -34,18 +40,57 @@ public List matchKeyword(List
articles, Integer au if (keywords.isEmpty()) { break; } + List keywordIds = keywords.stream() + .map(ArticleKeyword::getId) + .toList(); + Map> userMapsByKeywordId = articleKeywordUserMapRepository + .findAllByArticleKeywordIdIn(keywordIds) + .stream() + .filter(keywordUserMap -> !keywordUserMap.getIsDeleted()) + .collect(Collectors.groupingBy( + keywordUserMap -> keywordUserMap.getArticleKeyword().getId(), + LinkedHashMap::new, + Collectors.toList() + )); for (Article article : articles) { String title = article.getTitle(); for (ArticleKeyword keyword : keywords) { - if (title.contains(keyword.getKeyword())) { - keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, keyword)); + if (!title.contains(keyword.getKeyword())) { + continue; + } + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId + .computeIfAbsent(article.getId(), ignored -> new LinkedHashMap<>()); + + for (ArticleKeywordUserMap keywordUserMap : + userMapsByKeywordId.getOrDefault(keyword.getId(), List.of())) { + Integer userId = keywordUserMap.getUser().getId(); + matchedKeywordByUserId.merge( + userId, + keyword.getKeyword(), + this::pickHigherPriorityKeyword + ); } } } offset += KEYWORD_BATCH_SIZE; } + List keywordEvents = new ArrayList<>(); + for (Article article : articles) { + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId.get(article.getId()); + if (matchedKeywordByUserId != null && !matchedKeywordByUserId.isEmpty()) { + keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, matchedKeywordByUserId)); + } + } + return keywordEvents; } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; + } + return previousKeyword; + } } diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 30be4c43e2..53f985fe77 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -3,18 +3,24 @@ import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.transaction.event.TransactionalEventListener; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.domain.notification.model.Notification; @@ -38,35 +44,67 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 @TransactionalEventListener public void onKeywordRequest(ArticleKeywordEvent event) { + Map matchedKeywordByUserId = event.matchedKeywordByUserId(); + + if (matchedKeywordByUserId.isEmpty()) { + return; + } + Article article = articleRepository.getById(event.articleId()); Board board = article.getBoard(); - List notifications = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD) + Map keywordSubscribersByUserId = notificationSubscribeRepository + .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) .stream() .filter(this::hasDeviceToken) - .filter(subscribe -> isKeywordRegistered(event, subscribe)) - .filter(subscribe -> isNewNotifiedArticleId(event.articleId(), subscribe)) + .collect(Collectors.toMap( + subscribe -> subscribe.getUser().getId(), + Function.identity(), + (existing, ignored) -> existing, + LinkedHashMap::new + )); + + Set matchedUserIds = keywordSubscribersByUserId.keySet().stream() + .filter(matchedKeywordByUserId::containsKey) + .collect(Collectors.toSet()); + + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds( + event.articleId(), + matchedUserIds + ); + + List notifications = keywordSubscribersByUserId.values().stream() + .filter(subscribe -> matchedUserIds.contains(subscribe.getUser().getId())) + .filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !isMyArticle(event, subscribe)) - .map(subscribe -> createAndRecordNotification(article, board, event.keyword(), subscribe)) + .map(subscribe -> createNotification( + article, + board, + matchedKeywordByUserId.get(subscribe.getUser().getId()), + subscribe + )) .toList(); - notificationService.pushNotifications(notifications); + List deliveryResults = + notificationService.pushNotificationsWithResult(notifications); + for (NotificationService.NotificationDeliveryResult deliveryResult : deliveryResults) { + if (deliveryResult.delivered()) { + keywordService.createNotifiedArticleStatus(deliveryResult.notification().getUser().getId(), article.getId()); + } + } } private boolean hasDeviceToken(NotificationSubscribe subscribe) { - return subscribe.getUser().getDeviceToken() != null; + return StringUtils.hasText(subscribe.getUser().getDeviceToken()); } - private boolean isKeywordRegistered(ArticleKeywordEvent event, NotificationSubscribe subscribe) { - return event.keyword().getArticleKeywordUserMaps().stream() - .filter(map -> !map.getIsDeleted()) - .anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId())); - } - - private boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) { - Integer userId = subscribe.getUser().getId(); - return !userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId); + private Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { + if (subscriberUserIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) + ); } private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) { @@ -75,26 +113,23 @@ private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe sub return Objects.equals(authorId, subscriberId); } - private Notification createAndRecordNotification( + private Notification createNotification( Article article, Board board, - ArticleKeyword keyword, + String keyword, NotificationSubscribe subscribe ) { - Integer userId = subscribe.getUser().getId(); - String description = generateDescription(keyword.getKeyword()); + String description = generateDescription(keyword); Notification notification = notificationFactory.generateKeywordNotification( KEYWORD, article.getId(), - keyword.getKeyword(), + keyword, article.getTitle(), board.getId(), description, subscribe.getUser() ); - - keywordService.createNotifiedArticleStatus(userId, article.getId()); return notification; } diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java index 575e51ee1f..9e066cb298 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java @@ -7,4 +7,6 @@ public interface NotificationRepository extends Repository { Notification save(Notification notification); + + void saveAll(Iterable notifications); } diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java index c868f1ec87..f9d00d2541 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java @@ -17,6 +17,17 @@ public interface NotificationSubscribeRepository extends Repository findAllBySubscribeTypeAndDetailTypeIsNull(NotificationSubscribeType type); + @Query(""" + SELECT ns + FROM NotificationSubscribe ns + JOIN FETCH ns.user + WHERE ns.subscribeType = :subscribeType + AND ns.detailType IS NULL + """) + List findAllBySubscribeTypeAndDetailTypeIsNullWithUser( + @Param("subscribeType") NotificationSubscribeType subscribeType + ); + boolean existsByUserIdAndSubscribeTypeAndDetailTypeIsNull(Integer userId, NotificationSubscribeType type); boolean existsByUserIdAndSubscribeTypeAndDetailType( diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java new file mode 100644 index 0000000000..63deb3cb5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.notification.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationPersistenceService { + + private final NotificationRepository notificationRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAfterSend(Notification notification) { + notificationRepository.save(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index f449dc8a96..7a7c686cde 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -4,10 +4,13 @@ import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.getParentType; +import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import in.koreatech.koin.domain.dining.model.DiningType; import in.koreatech.koin.domain.notification.dto.NotificationStatusResponse; @@ -24,42 +27,68 @@ import in.koreatech.koin.infrastructure.fcm.FcmClient; import io.micrometer.common.util.StringUtils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class NotificationService { + public record NotificationDeliveryResult(Notification notification, boolean delivered) {} + private final UserRepository userRepository; private final NotificationRepository notificationRepository; + private final NotificationPersistenceService notificationPersistenceService; private final FcmClient fcmClient; private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; + @Transactional public void pushNotifications(List notifications) { - for (Notification notification : notifications) { - pushNotification(notification); + if (notifications.isEmpty()) { + return; + } + notificationRepository.saveAll(notifications); + runAfterCommit(() -> notifications.forEach(this::sendNotificationSafely)); + } + + @Transactional + public List pushNotificationsWithResult(List notifications) { + if (notifications.isEmpty()) { + return List.of(); } + + List deliveryResults = new ArrayList<>(notifications.size()); + // afterCommit 콜백은 트랜잭션 프록시가 반환되기 전에 실행되므로 호출자는 채워진 결과를 받는다. + runAfterCommit(() -> notifications.forEach(notification -> + deliveryResults.add(pushNotificationWithResult(notification)) + )); + return deliveryResults; } @Transactional public void pushNotification(Notification notification) { - notificationRepository.save(notification); - String deviceToken = notification.getUser().getDeviceToken(); - fcmClient.sendMessage( - deviceToken, - notification.getTitle(), - notification.getMessage(), - notification.getImageUrl(), - notification.getMobileAppPath(), - notification.getSchemeUri(), - notification.getType().toLowerCase() - ); + pushNotifications(List.of(notification)); + } + + private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { + try { + boolean delivered = sendNotificationWithResult(notification); + if (!delivered) { + return new NotificationDeliveryResult(notification, false); + } + saveNotificationAfterSend(notification); + return new NotificationDeliveryResult(notification, true); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + return new NotificationDeliveryResult(notification, false); + } } public NotificationStatusResponse getNotificationInfo(Integer userId) { User user = userRepository.getById(userId); - boolean isPermit = user.getDeviceToken() != null; + boolean isPermit = StringUtils.isNotBlank(user.getDeviceToken()); List subscribeList = notificationSubscribeRepository.findAllByUserId(userId); return NotificationStatusResponse.of(isPermit, subscribeList); } @@ -122,6 +151,7 @@ public void rejectNotificationByDetailType(Integer userId, NotificationDetailSub notificationSubscribeRepository.deleteByUserIdAndDetailType(userId, detailType); } + @Transactional public void sendDiningSoldOutNotifications(Integer dinningId, String place, DiningType diningType) { NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(diningType); var notifications = notificationSubscribeRepository.findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, detailType) @@ -136,6 +166,65 @@ public void sendDiningSoldOutNotifications(Integer dinningId, String place, Dini pushNotifications(notifications); } + private void sendNotificationSafely(Notification notification) { + try { + sendNotification(notification); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + } + } + + private void sendNotification(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + fcmClient.sendMessage( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private boolean sendNotificationWithResult(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + return fcmClient.sendMessageWithResult( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private void saveNotificationAfterSend(Notification notification) { + try { + notificationPersistenceService.saveAfterSend(notification); + } catch (Exception e) { + log.warn("발송된 알림 저장 중 예외가 발생했습니다.", e); + } + } + + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + + // Rollback된 데이터에 대한 푸시 전송을 막기 위해 커밋 이후에만 FCM을 호출한다. + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void ensureUserDeviceToken(String deviceToken) { if (StringUtils.isBlank(deviceToken)) { throw NotificationNotPermitException.withDetail("user.deviceToken: null"); diff --git a/src/main/java/in/koreatech/koin/domain/upload/controller/UploadApi.java b/src/main/java/in/koreatech/koin/domain/upload/controller/UploadApi.java index ae575f658a..ba4ba340bc 100644 --- a/src/main/java/in/koreatech/koin/domain/upload/controller/UploadApi.java +++ b/src/main/java/in/koreatech/koin/domain/upload/controller/UploadApi.java @@ -53,6 +53,10 @@ public interface UploadApi { - coop - admin - banner + - callvan_report + - callvan_chat + - lost_items + - club """) @PostMapping("/{domain}/upload/url") ResponseEntity getPresignedUrl( diff --git a/src/main/java/in/koreatech/koin/domain/user/verification/service/UserVerificationService.java b/src/main/java/in/koreatech/koin/domain/user/verification/service/UserVerificationService.java index 973fb30a71..a07effa5da 100644 --- a/src/main/java/in/koreatech/koin/domain/user/verification/service/UserVerificationService.java +++ b/src/main/java/in/koreatech/koin/domain/user/verification/service/UserVerificationService.java @@ -33,7 +33,7 @@ public UserVerificationService( VerificationCountRedisRepository verificationCountRedisRepository, VerificationNumberGenerator verificationNumberGenerator, ApplicationEventPublisher eventPublisher, - @Value("${user.verification.max-verification-count:5}") int maxVerificationCount + @Value("${user.verification.max-verification-count}") int maxVerificationCount ) { this.verificationCodeRedisRepository = verificationCodeRedisRepository; this.verificationCountRedisRepository = verificationCountRedisRepository; diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index 055a9959b5..15f486b981 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -91,6 +91,7 @@ public enum ApiResponseCode { CALLVAN_POST_REOPEN_FAILED_TIME(HttpStatus.BAD_REQUEST, "출발 시간이 지나서 모집을 다시 열 수 없습니다."), CALLVAN_POST_AUTHOR(HttpStatus.BAD_REQUEST, "콜벤 게시글 작성자는 나갈 수 없습니다"), CALLVAN_REPORT_SELF(HttpStatus.BAD_REQUEST, "자기 자신은 신고할 수 없습니다."), + CALLVAN_REPORT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 콜벤 신고입니다."), /** * 401 Unauthorized (인증 필요) @@ -114,6 +115,7 @@ public enum ApiResponseCode { FORBIDDEN_AUTHOR(HttpStatus.FORBIDDEN, "게시글 접근 권한이 없습니다."), FORBIDDEN_PARTICIPANT(HttpStatus.FORBIDDEN, "콜벤 게시글 참여자가 아닙니다."), CALLVAN_REPORT_ONLY_PARTICIPANT(HttpStatus.FORBIDDEN, "같은 콜벤팟 참여자만 신고할 수 있습니다."), + FORBIDDEN_CALLVAN_RESTRICTED_USER(HttpStatus.FORBIDDEN, "콜벤 기능 이용이 제한된 사용자입니다."), /** * 404 Not Found (리소스를 찾을 수 없음) @@ -142,6 +144,7 @@ public enum ApiResponseCode { NOT_FOUND_SHOP_ORDER_SERVICE_REQUEST(HttpStatus.NOT_FOUND, "상점 서비스 전환 요청이 존재하지 않습니다."), NOT_FOUND_CHAT_PARTNER(HttpStatus.NOT_FOUND, "채팅 상대방이 존재하지 않습니다."), NOT_FOUND_IMAGE(HttpStatus.NOT_FOUND, "이미지를 찾을 수 없습니다"), + NOT_FOUND_CALLVAN_REPORT(HttpStatus.NOT_FOUND, "콜벤 신고 내역을 찾을 수 없습니다."), /** * 409 CONFLICT (중복 혹은 충돌) diff --git a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java index f14b013319..776e98d5fc 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java +++ b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.ApnsConfig; @@ -33,23 +34,39 @@ public void sendMessage( String schemeUri, String type ) { - if (targetDeviceToken == null) { - return; + sendMessageWithResult(targetDeviceToken, title, content, imageUrl, path, schemeUri, type); + } + + public boolean sendMessageWithResult( + String targetDeviceToken, + String title, + String content, + String imageUrl, + MobileAppPath path, + String schemeUri, + String type + ) { + if (!StringUtils.hasText(targetDeviceToken)) { + return false; } - log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); + try { + log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); - ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); - AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); + AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + + Message message = Message.builder() + .setToken(targetDeviceToken) + .setApnsConfig(apnsConfig) + .setAndroidConfig(androidConfig) + .build(); - Message message = Message.builder() - .setToken(targetDeviceToken) - .setApnsConfig(apnsConfig) - .setAndroidConfig(androidConfig).build(); - try { String result = FirebaseMessaging.getInstance().send(message); log.info("FCM 알림 전송 성공: {}", result); + return true; } catch (Exception e) { log.warn("FCM 알림 전송 실패", e); + return false; } } diff --git a/src/main/java/in/koreatech/koin/infrastructure/s3/model/ImageUploadDomain.java b/src/main/java/in/koreatech/koin/infrastructure/s3/model/ImageUploadDomain.java index eeb5ad7061..4cf1902f87 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/s3/model/ImageUploadDomain.java +++ b/src/main/java/in/koreatech/koin/infrastructure/s3/model/ImageUploadDomain.java @@ -20,6 +20,8 @@ public enum ImageUploadDomain { LOST_ITEMS, BANNER, CLUB, + CALLVAN_REPORT, + CALLVAN_CHAT ; public static ImageUploadDomain from(String domain) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000000..f90606c146 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,128 @@ +jwt: + secret-key: ${JWT_SECRET_KEY} + access-token: + expiration-time: ${JWT_ACCESS_TOKEN_EXPIRATION_TIME} + +spring: + mvc: + throw-exception-if-no-handler-found: true + web: + resources: + add-mappings: false + lifecycle: + timeout-per-shutdown-phase: 5s + servlet: + multipart: + max-request-size: 10MB + max-file-size: 10MB + + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + + jpa: + open-in-view: false + properties: + hibernate: + default_batch_fetch_size: 100 + hibernate: + ddl-auto: validate + + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + mongodb: + uri: ${MONGODB_URI} + + thymeleaf: + prefix: "classpath:/mail/" + suffix: ".html" + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DATASOURCE_URL} + username: ${DATASOURCE_USERNAME} + password: ${DATASOURCE_PASSWORD} + + profiles: + active: ${SPRING_PROFILES_ACTIVE} + +server: + tomcat: + max-http-form-post-size: 10MB + lifecycle: + timeout-per-shutdown-phase: 5s + +swagger: + server-url: ${SWAGGER_SERVER_URL} + +aws: + ses: + access-key: ${AWS_SES_ACCESS_KEY} + secret-key: ${AWS_SES_SECRET_KEY} + +s3: + bucket: ${S3_BUCKET} + custom_domain: ${S3_CUSTOM_DOMAIN} + +slack: + koin_event_notify_url: ${SLACK_KOIN_EVENT_NOTIFY_URL} + koin_owner_event_notify_url: ${SLACK_KOIN_OWNER_EVENT_NOTIFY_URL} + koin_shop_review_notify_url: ${SLACK_KOIN_SHOP_REVIEW_NOTIFY_URL} + koin_lost_item_notify_url: ${SLACK_KOIN_LOST_ITEM_NOTIFY_URL} + logging: + error: ${SLACK_LOGGING_ERROR_URL} + +koin: + admin: + shop: + url: ${KOIN_ADMIN_SHOP_URL} + review: + url: ${KOIN_ADMIN_REVIEW_URL} + +OPEN_API_KEY_PUBLIC: ${OPEN_API_KEY_PUBLIC} +OPEN_API_KEY_TMONEY: ${OPEN_API_KEY_TMONEY} + +fcm: + koin: + url: ${FCM_KOIN_URL} + +naver: + accessKey: ${NAVER_ACCESS_KEY} + secretKey: ${NAVER_SECRET_KEY} + sms: + apiUrl: ${NAVER_SMS_API_URL} + serviceId: ${NAVER_SMS_SERVICE_ID} + fromNumber: ${NAVER_SMS_FROM_NUMBER} + +cors: + allowedOrigins: ${CORS_ALLOWED_ORIGINS} + +springdoc: + use-fqn: true + +cloudfront: + distribution-id: ${CLOUDFRONT_DISTRIBUTION_ID} + region: ${CLOUDFRONT_REGION} + +user: + verification: + max-verification-count: ${MAX_VERIFICATION_COUNT} + +address: + api: + url: ${ADDRESS_API_URL} + key: ${ADDRESS_API_KEY} + +toss-payment: + secret-key: ${TOSS_PAYMENT_SECRET_KEY} + api-base-url: ${TOSS_PAYMENT_API_BASE_URL} + +management: + endpoint: + health: + probes: + enabled: true diff --git a/src/main/resources/db/migration/V232__add_call_van_report_attachment.sql b/src/main/resources/db/migration/V232__add_call_van_report_attachment.sql new file mode 100644 index 0000000000..f27d166fce --- /dev/null +++ b/src/main/resources/db/migration/V232__add_call_van_report_attachment.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `koin`.`callvan_report_attachment` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `report_id` INT NOT NULL COMMENT 'callvan_report.id', + `attachment_type` VARCHAR(30) NOT NULL COMMENT 'IMAGE', + `url` VARCHAR(500) NOT NULL COMMENT 'url', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX `idx_report_attachment_report` (`report_id`, `is_deleted`) +); diff --git a/src/main/resources/db/migration/V233__add_callvan_report_process.sql b/src/main/resources/db/migration/V233__add_callvan_report_process.sql new file mode 100644 index 0000000000..d68c3462b3 --- /dev/null +++ b/src/main/resources/db/migration/V233__add_callvan_report_process.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `koin`.`callvan_report_process` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `report_id` INT NOT NULL COMMENT 'callvan_report.id', + `processor_id` INT NOT NULL COMMENT '어드민 처리자', + `process_type` VARCHAR(50) NOT NULL COMMENT 'WARNING, TEMPORARY_RESTRICTION_14_DAYS, PERMANENT_RESTRICTION, REJECT', + `restricted_until` DATETIME NULL COMMENT '14일 제한 종료일', + `is_deleted` TINYINT(1) NOT NULL DEFAULT '0', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX `idx_callvan_report_process_report` (`report_id`, `is_deleted`), + INDEX `idx_callvan_report_process_processor` (`processor_id`, `is_deleted`), + INDEX `idx_callvan_report_process_type_until` (`process_type`, `restricted_until`, `is_deleted`) +); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java new file mode 100644 index 0000000000..6ff874eb0c --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.unit.domain.community.keyword.service; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class KeywordServiceTest { + + @InjectMocks + private KeywordService keywordService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordSuggestRepository articleKeywordSuggestRepository; + + @Mock + private ArticleRepository articleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordExtractor keywordExtractor; + + @Test + @DisplayName("중복 게시글 ID 요청은 제거 후 키워드 알림 이벤트를 발행한다.") + void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArticle() { + KeywordNotificationRequest request = new KeywordNotificationRequest(List.of(10, 10, 11, 11)); + Article article10 = mock(Article.class); + Article article11 = mock(Article.class); + when(article10.getId()).thenReturn(10); + when(article11.getId()).thenReturn(11); + when(articleRepository.findAllByIdIn(List.of(10, 11))).thenReturn(List.of(article11, article10)); + + ArticleKeywordEvent event10 = new ArticleKeywordEvent( + 10, + null, + Map.of(1, "A") + ); + ArticleKeywordEvent event11 = new ArticleKeywordEvent( + 11, + null, + Map.of(2, "B") + ); + when(keywordExtractor.matchKeyword(List.of(article10, article11), null)).thenReturn(List.of(event10, event11)); + + keywordService.sendKeywordNotification(request); + + verify(articleRepository).findAllByIdIn(List.of(10, 11)); + verify(keywordExtractor).matchKeyword(List.of(article10, article11), null); + verify(eventPublisher).publishEvent(event10); + verify(eventPublisher).publishEvent(event11); + verifyNoMoreInteractions(eventPublisher); + } + + @Test + @DisplayName("업데이트 알림 대상 게시글이 없으면 아무 작업도 수행하지 않는다.") + void sendKeywordNotification_withEmptyArticleIds_doesNothing() { + keywordService.sendKeywordNotification(new KeywordNotificationRequest(List.of())); + + verifyNoInteractions(articleRepository); + verifyNoInteractions(keywordExtractor); + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("발송 이력 저장은 DB upsert를 사용한다.") + void createNotifiedArticleStatus_usesAtomicUpsert() { + keywordService.createNotifiedArticleStatus(1, 100); + + verify(userNotificationStatusRepository).upsertLastNotifiedArticleId(1, 100); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java new file mode 100644 index 0000000000..0c9c4f6076 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java @@ -0,0 +1,154 @@ +package in.koreatech.koin.unit.domain.community.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class KeywordExtractorTest { + + @InjectMocks + private KeywordExtractor keywordExtractor; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Test + @DisplayName("한 게시글에 여러 키워드가 매칭되면 사용자별 키워드를 병합한 이벤트 한 건만 생성된다.") + void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("근로장학생 모집"); + + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keywordA = createKeyword(1, "근로", subscriber); + ArticleKeyword keywordB = createKeyword(2, "근로장학", subscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keywordA, keywordB)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + keywordA.getArticleKeywordUserMaps().get(0), + keywordB.getArticleKeywordUserMaps().get(0) + )); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).hasSize(1); + ArticleKeywordEvent event = result.get(0); + assertThat(event.articleId()).isEqualTo(1); + assertThat(event.authorId()).isNull(); + assertThat(event.matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로장학")); + } + + @Test + @DisplayName("매칭되는 키워드가 없으면 이벤트를 생성하지 않는다.") + void matchKeyword_whenNoKeywordsMatch_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("근로장학생 모집"); + + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keyword = createKeyword(1, "장학금", subscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of(keyword.getArticleKeywordUserMaps().get(0))); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("여러 게시글이 각각 다른 키워드에 매칭되면 게시글별 이벤트를 생성한다.") + void matchKeyword_withMultipleArticles_createsEventPerArticle() { + Article firstArticle = mock(Article.class); + when(firstArticle.getId()).thenReturn(1); + when(firstArticle.getTitle()).thenReturn("근로장학생 모집"); + + Article secondArticle = mock(Article.class); + when(secondArticle.getId()).thenReturn(2); + when(secondArticle.getTitle()).thenReturn("국가장학금 신청 안내"); + + User firstSubscriber = UserFixture.id_설정_코인_유저(1); + User secondSubscriber = UserFixture.id_설정_코인_유저(2); + ArticleKeyword firstKeyword = createKeyword(1, "근로", firstSubscriber); + ArticleKeyword secondKeyword = createKeyword(2, "장학금", secondSubscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(firstKeyword, secondKeyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + firstKeyword.getArticleKeywordUserMaps().get(0), + secondKeyword.getArticleKeywordUserMaps().get(0) + )); + + List result = keywordExtractor.matchKeyword(List.of(firstArticle, secondArticle), null); + + assertThat(result).hasSize(2); + assertThat(result.get(0).articleId()).isEqualTo(1); + assertThat(result.get(0).matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로")); + assertThat(result.get(1).articleId()).isEqualTo(2); + assertThat(result.get(1).matchedKeywordByUserId()).isEqualTo(Map.of(2, "장학금")); + } + + @Test + @DisplayName("등록된 키워드가 없으면 빈 결과를 반환한다.") + void matchKeyword_whenNoKeywordsExist_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(articleKeywordRepository.findAll(any(Pageable.class))).thenReturn(List.of()); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + verifyNoInteractions(articleKeywordUserMapRepository); + } + + private ArticleKeyword createKeyword(Integer keywordId, String keyword, User... users) { + ArticleKeyword articleKeyword = ArticleKeyword.builder() + .keyword(keyword) + .build(); + ReflectionTestUtils.setField(articleKeyword, "id", keywordId); + for (User user : users) { + ArticleKeywordUserMap userMap = ArticleKeywordUserMap.builder() + .articleKeyword(articleKeyword) + .user(user) + .build(); + articleKeyword.addUserMap(userMap); + } + return articleKeyword; + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java new file mode 100644 index 0000000000..2b8b4271e3 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -0,0 +1,281 @@ +package in.koreatech.koin.unit.domain.notification.eventlistener; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.Board; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.notification.eventlistener.ArticleKeywordEventListener; +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class ArticleKeywordEventListenerTest { + + @InjectMocks + private ArticleKeywordEventListener articleKeywordEventListener; + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationFactory notificationFactory; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordService keywordService; + + @Mock + private ArticleRepository articleRepository; + + @Test + @DisplayName("중복 구독이 있어도 사용자당 알림은 한 번만 발송된다.") + void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { + Integer articleId = 100; + Integer boardId = 12; + Integer userId = 1; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribeA = createKeywordSubscribe(subscriber); + NotificationSubscribe subscribeB = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribeA, subscribeB)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(subscriber); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, true))); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(keywordService, times(1)).createNotifiedArticleStatus(userId, articleId); + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + } + + @Test + @DisplayName("게시글 작성자 본인에게는 키워드 알림을 보내지 않는다.") + void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { + Integer articleId = 200; + Integer userId = 2; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, userId, Map.of(userId, "A")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); + } + + @Test + @DisplayName("이미 해당 게시글 알림을 받은 사용자는 다시 발송하지 않는다.") + void onKeywordRequest_whenAlreadyNotified_skipsNotification() { + Integer articleId = 300; + Integer userId = 3; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "C")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of(userId)); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); + } + + @Test + @DisplayName("알림 전송 실패 시 발송 이력을 저장하지 않는다.") + void onKeywordRequest_whenDeliveryFails_doesNotRecordNotifiedStatus() { + Integer articleId = 400; + Integer boardId = 15; + Integer userId = 4; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, false))); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + } + + @Test + @DisplayName("기발송 사용자 조회는 매칭된 사용자 ID만 대상으로 수행한다.") + void onKeywordRequest_queriesNotifiedStatusOnlyForMatchedUsers() { + Integer articleId = 500; + Integer boardId = 16; + Integer matchedUserId = 5; + Integer unmatchedUserId = 6; + + User matchedUser = UserFixture.id_설정_코인_유저(matchedUserId); + matchedUser.permitNotification("matched-device-token"); + User unmatchedUser = UserFixture.id_설정_코인_유저(unmatchedUserId); + unmatchedUser.permitNotification("unmatched-device-token"); + + NotificationSubscribe matchedSubscribe = createKeywordSubscribe(matchedUser); + NotificationSubscribe unmatchedSubscribe = createKeywordSubscribe(unmatchedUser); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(matchedUserId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(matchedSubscribe, unmatchedSubscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId))) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(userNotificationStatusRepository) + .findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId)); + } + + private NotificationSubscribe createKeywordSubscribe(User user) { + return NotificationSubscribe.builder() + .subscribeType(ARTICLE_KEYWORD) + .user(user) + .build(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000000..7e51728538 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,162 @@ +package in.koreatech.koin.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationPersistenceService; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @InjectMocks + private NotificationService notificationService; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationPersistenceService notificationPersistenceService; + + @Mock + private FcmClient fcmClient; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private NotificationFactory notificationFactory; + + @Test + @DisplayName("알림 전송 결과 조회는 전송 성공 시에만 알림 레코드를 저장한다.") + void pushNotificationsWithResult_whenDelivered_savesNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isTrue(); + InOrder inOrder = inOrder(fcmClient, notificationPersistenceService); + inOrder.verify(fcmClient).sendMessageWithResult( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); + inOrder.verify(notificationPersistenceService).saveAfterSend(notification); + } + + @Test + @DisplayName("알림 전송 실패 시 알림 레코드를 저장하지 않는다.") + void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(false); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isFalse(); + verify(notificationPersistenceService, never()).saveAfterSend(notification); + } + + @Test + @DisplayName("배치 알림 중 일부 저장이 실패해도 발송 결과는 유지하고 다음 알림을 계속 처리한다.") + void pushNotificationsWithResult_whenSaveFails_keepsDeliveryResultAndContinuesNextNotification() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, true); + doThrow(new RuntimeException("save fail")).when(notificationPersistenceService).saveAfterSend(firstNotification); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isTrue(); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService).saveAfterSend(secondNotification); + } + + @Test + @DisplayName("배치 알림은 전송 성공 여부를 각각 반환하고 성공한 알림만 저장한다.") + void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, false); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isFalse(); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService, never()).saveAfterSend(secondNotification); + } + + @Test + @DisplayName("단건 알림 전송은 저장 후 FCM 전송을 수행한다.") + void pushNotification_savesNotificationBeforeSend() { + Notification notification = createNotification("device-token"); + + notificationService.pushNotification(notification); + + InOrder inOrder = inOrder(notificationRepository, fcmClient); + inOrder.verify(notificationRepository).saveAll(List.of(notification)); + inOrder.verify(fcmClient).sendMessage( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); + verify(notificationRepository, never()).save(notification); + } + + private Notification createNotification(String deviceToken) { + User user = UserFixture.id_설정_코인_유저(1); + user.permitNotification(deviceToken); + + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(user); + when(notification.getTitle()).thenReturn("title"); + when(notification.getMessage()).thenReturn("message"); + when(notification.getImageUrl()).thenReturn(null); + when(notification.getMobileAppPath()).thenReturn(null); + when(notification.getSchemeUri()).thenReturn("scheme-uri"); + when(notification.getType()).thenReturn("message"); + return notification; + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index cc2c0ae75f..5e31a3e67e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -98,6 +98,9 @@ naver: serviceId: ncp:sms:kr:test fromNumber: "01012331234" +user: + verification: + max-verification-count: 5 OPEN_API_KEY_PUBLIC: testck