diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 0a96fb08..7419faee 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -37,6 +37,106 @@ jobs: target: ~/nadab/ overwrite: true + - name: πŸ” Prepare deployment secrets + env: + INSIGHT_PROMPT_B64: ${{ secrets.INSIGHT_PROMPT_B64 }} + INSIGHT_WITH_IMAGE_PROMPT_B64: ${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }} + WEEKLY_PROMPT_B64: ${{ secrets.WEEKLY_PROMPT_B64 }} + MONTHLY_V1_PROMPT_B64: ${{ secrets.MONTHLY_V1_PROMPT_B64 }} + MONTHLY_V2_BASELINE_PROMPT_B64: ${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }} + MONTHLY_V2_COMPARISON_PROMPT_B64: ${{ secrets.MONTHLY_V2_COMPARISON_PROMPT_B64 }} + EVIDENCE_CARD_PROMPT_B64: ${{ secrets.EVIDENCE_CARD_PROMPT_B64 }} + PATTERN_EXTRACT_PROMPT_B64: ${{ secrets.PATTERN_EXTRACT_PROMPT_B64 }} + TYPE_SELECT_PROMPT_B64: ${{ secrets.TYPE_SELECT_PROMPT_B64 }} + TYPE_PROMPT_B64: ${{ secrets.TYPE_PROMPT_B64 }} + REPAIR_TYPE_PROMPT_B64: ${{ secrets.REPAIR_TYPE_PROMPT_B64 }} + ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64: ${{ secrets.ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64 }} + ASK_CHAT_ANSWER_USER_PROMPT_B64: ${{ secrets.ASK_CHAT_ANSWER_USER_PROMPT_B64 }} + NOTIFICATION_MESSAGES_B64: ${{ secrets.NOTIFICATION_MESSAGES_B64 }} + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + FRONTEND_DEV_URL: ${{ secrets.FRONTEND_DEV_URL }} + FRONTEND_DEV_URL_2: ${{ secrets.FRONTEND_DEV_URL_2 }} + FRONTEND_DEV_URL_3: ${{ secrets.FRONTEND_DEV_URL_3 }} + SWAGGER_SERVER_URL: ${{ secrets.SWAGGER_SERVER_URL }} + SWAGGER_ORIGIN_PUBLIC_IP: ${{ secrets.SWAGGER_ORIGIN_PUBLIC_IP }} + SWAGGER_ORIGIN_DOMAIN: ${{ secrets.SWAGGER_ORIGIN_DOMAIN }} + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }} + NAVER_REDIRECT_URI: ${{ secrets.NAVER_REDIRECT_URI }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_ANDROID_CLIENT_ID: ${{ secrets.GOOGLE_ANDROID_CLIENT_ID }} + GOOGLE_IOS_CLIENT_ID: ${{ secrets.GOOGLE_IOS_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} + KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }} + KAKAO_APP_ID: ${{ secrets.KAKAO_APP_ID }} + APPLE_CLIENT_ID: ${{ secrets.APPLE_CLIENT_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} + APPLE_PRIVATE_KEY: ${{ secrets.APPLE_PRIVATE_KEY }} + IAM_ACCESS_KEY: ${{ secrets.IAM_ACCESS_KEY }} + IAM_SECRET_KEY: ${{ secrets.IAM_SECRET_KEY }} + BUCKET_NAME: ${{ secrets.BUCKET_NAME }} + PROFILE_IMAGE_BASE_URL: ${{ secrets.PROFILE_IMAGE_BASE_URL }} + CLOUDFRONT_KEY_PAIR_ID: ${{ secrets.CLOUDFRONT_KEY_PAIR_ID }} + CLOUDFRONT_PRIVATE_KEY: ${{ secrets.CLOUDFRONT_PRIVATE_KEY }} + KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} + MAIL_ADDRESS: ${{ secrets.MAIL_ADDRESS }} + MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_GENAI_API_KEY: ${{ secrets.GOOGLE_GENAI_API_KEY }} + FIREBASE_ADMIN_KEY: ${{ secrets.FIREBASE_ADMIN_KEY }} + ADMIN_PAGE_PASSWORD: ${{ secrets.ADMIN_PAGE_PASSWORD }} + DEV_TEST_ACCOUNT_PASSWORD: ${{ secrets.DEV_TEST_ACCOUNT_PASSWORD }} + run: | + set -euo pipefail + umask 077 + mkdir -p deploy-secrets + + SECRET_NAMES=( + INSIGHT_PROMPT_B64 INSIGHT_WITH_IMAGE_PROMPT_B64 WEEKLY_PROMPT_B64 + MONTHLY_V1_PROMPT_B64 MONTHLY_V2_BASELINE_PROMPT_B64 MONTHLY_V2_COMPARISON_PROMPT_B64 + EVIDENCE_CARD_PROMPT_B64 PATTERN_EXTRACT_PROMPT_B64 TYPE_SELECT_PROMPT_B64 + TYPE_PROMPT_B64 REPAIR_TYPE_PROMPT_B64 ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64 + ASK_CHAT_ANSWER_USER_PROMPT_B64 NOTIFICATION_MESSAGES_B64 + DB_URL DB_USERNAME DB_PASSWORD JWT_SECRET + FRONTEND_DEV_URL FRONTEND_DEV_URL_2 FRONTEND_DEV_URL_3 + SWAGGER_SERVER_URL SWAGGER_ORIGIN_PUBLIC_IP SWAGGER_ORIGIN_DOMAIN + NAVER_CLIENT_ID NAVER_CLIENT_SECRET NAVER_REDIRECT_URI + GOOGLE_CLIENT_ID GOOGLE_ANDROID_CLIENT_ID GOOGLE_IOS_CLIENT_ID + GOOGLE_CLIENT_SECRET GOOGLE_REDIRECT_URI + KAKAO_CLIENT_ID KAKAO_CLIENT_SECRET KAKAO_REDIRECT_URI KAKAO_APP_ID + APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY + IAM_ACCESS_KEY IAM_SECRET_KEY BUCKET_NAME PROFILE_IMAGE_BASE_URL + CLOUDFRONT_KEY_PAIR_ID CLOUDFRONT_PRIVATE_KEY KMS_KEY_ID + MAIL_ADDRESS MAIL_PASSWORD OPENAI_API_KEY ANTHROPIC_API_KEY + GOOGLE_GENAI_API_KEY FIREBASE_ADMIN_KEY ADMIN_PAGE_PASSWORD DEV_TEST_ACCOUNT_PASSWORD + ) + + for name in "${SECRET_NAMES[@]}"; do + printf '%s' "${!name}" | base64 -w 0 > "deploy-secrets/$name" + done + + # scp-action Docker μ»¨ν…Œμ΄λ„ˆκ°€ λ‹€λ₯Έ UID둜 νŒŒμΌμ„ 읽을 수 μžˆλ„λ‘ 전솑 μ§μ „μ—λ§Œ κΆŒν•œ ν—ˆμš© + chmod 755 deploy-secrets + chmod 644 deploy-secrets/* + + - name: πŸ“€ Upload deployment secrets to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ec2-user + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + source: ./deploy-secrets + target: ~/nadab/ + overwrite: true + - name: πŸš€ Restart App on EC2 uses: appleboy/ssh-action@v1 with: @@ -44,7 +144,7 @@ jobs: username: ec2-user key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} script: | - set -euxo pipefail + set -euo pipefail cd ~/nadab echo "πŸ”Ž ν˜„μž¬ μ„œλ²„ 디렉토리: $(pwd)" @@ -59,112 +159,89 @@ jobs: JAR_NAME=$(basename "$JAR_PATH") echo "πŸ“Œ 배포할 JAR 선택됨: $JAR_PATH" - - # 싀행쀑인 ν”„λ‘œμ„ΈμŠ€ μ’…λ£Œ + + SECRETS_DIR="deploy-secrets" + [ -d "$SECRETS_DIR" ] || { echo "❌ 배포 Secret 디렉토리가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€"; exit 1; } + chmod 700 "$SECRETS_DIR" + chmod 600 "$SECRETS_DIR"/* + + cleanup_secrets() { + rm -f -- "$SECRETS_DIR"/* + rmdir -- "$SECRETS_DIR" + } + trap cleanup_secrets EXIT + + load_secret() { + local name="$1" + local path="$SECRETS_DIR/$name" + [ -f "$path" ] || { echo "❌ ν•„μˆ˜ Secret 파일 λˆ„λ½: $name"; exit 1; } + + local value + value="$(base64 -d "$path")" + printf -v "$name" '%s' "$value" + export "$name" + } + + load_prompt() { + local name="$1" + local path="$SECRETS_DIR/${name}_B64" + [ -f "$path" ] || { echo "❌ ν•„μˆ˜ ν”„λ‘¬ν”„νŠΈ 파일 λˆ„λ½: ${name}_B64"; exit 1; } + + local value + value="$(base64 -d "$path" | base64 -d | tr -d '\r')" + printf -v "$name" '%s' "$value" + export "$name" + } + + PROMPT_NAMES=( + INSIGHT_PROMPT INSIGHT_WITH_IMAGE_PROMPT WEEKLY_PROMPT + MONTHLY_V1_PROMPT MONTHLY_V2_BASELINE_PROMPT MONTHLY_V2_COMPARISON_PROMPT + EVIDENCE_CARD_PROMPT PATTERN_EXTRACT_PROMPT TYPE_SELECT_PROMPT TYPE_PROMPT + REPAIR_TYPE_PROMPT ASK_CHAT_ANSWER_SYSTEM_PROMPT ASK_CHAT_ANSWER_USER_PROMPT + NOTIFICATION_MESSAGES + ) + + SECRET_NAMES=( + DB_URL DB_USERNAME DB_PASSWORD JWT_SECRET + FRONTEND_DEV_URL FRONTEND_DEV_URL_2 FRONTEND_DEV_URL_3 + SWAGGER_SERVER_URL SWAGGER_ORIGIN_PUBLIC_IP SWAGGER_ORIGIN_DOMAIN + NAVER_CLIENT_ID NAVER_CLIENT_SECRET NAVER_REDIRECT_URI + GOOGLE_CLIENT_ID GOOGLE_ANDROID_CLIENT_ID GOOGLE_IOS_CLIENT_ID + GOOGLE_CLIENT_SECRET GOOGLE_REDIRECT_URI + KAKAO_CLIENT_ID KAKAO_CLIENT_SECRET KAKAO_REDIRECT_URI KAKAO_APP_ID + APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY + IAM_ACCESS_KEY IAM_SECRET_KEY BUCKET_NAME PROFILE_IMAGE_BASE_URL + CLOUDFRONT_KEY_PAIR_ID CLOUDFRONT_PRIVATE_KEY KMS_KEY_ID + MAIL_ADDRESS MAIL_PASSWORD OPENAI_API_KEY ANTHROPIC_API_KEY + GOOGLE_GENAI_API_KEY FIREBASE_ADMIN_KEY ADMIN_PAGE_PASSWORD DEV_TEST_ACCOUNT_PASSWORD + ) + + for name in "${PROMPT_NAMES[@]}"; do + load_prompt "$name" + done + + for name in "${SECRET_NAMES[@]}"; do + load_secret "$name" + done + + cleanup_secrets + trap - EXIT + + # Secret 볡원이 μ™„λ£Œλœ ν›„μ—λ§Œ κΈ°μ‘΄ ν”„λ‘œμ„ΈμŠ€ μ’…λ£Œ echo "πŸ›‘ κΈ°μ‘΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ’…λ£Œ 쀑..." pkill -f "$JAR_NAME" || true - - echo "πŸš€ μƒˆ 버전 μ‹€ν–‰..." - - # βœ… base64둜 μΈμ½”λ”©λœ ν”„λ‘¬ν”„νŠΈλ₯Ό ν™˜κ²½ λ³€μˆ˜μ— λ””μ½”λ”©ν•΄μ„œ λ„£κΈ° - INSIGHT_PROMPT_B64="${{ secrets.INSIGHT_PROMPT_B64 }}" - INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')" - export INSIGHT_PROMPT - - INSIGHT_WITH_IMAGE_PROMPT_B64="${{ secrets.INSIGHT_WITH_IMAGE_PROMPT_B64 }}" - INSIGHT_WITH_IMAGE_PROMPT="$(printf '%s' "$INSIGHT_WITH_IMAGE_PROMPT_B64" | base64 -d | tr -d '\r')" - export INSIGHT_WITH_IMAGE_PROMPT - - WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}" - WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" - export WEEKLY_PROMPT - - MONTHLY_V1_PROMPT_B64="${{ secrets.MONTHLY_V1_PROMPT_B64 }}" - MONTHLY_V1_PROMPT="$(printf '%s' "$MONTHLY_V1_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_V1_PROMPT - - MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" - MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_V2_BASELINE_PROMPT - MONTHLY_V2_COMPARISON_PROMPT_B64="${{ secrets.MONTHLY_V2_COMPARISON_PROMPT_B64 }}" - MONTHLY_V2_COMPARISON_PROMPT="$(printf '%s' "$MONTHLY_V2_COMPARISON_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_V2_COMPARISON_PROMPT - - EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" - EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" - export EVIDENCE_CARD_PROMPT - - PATTERN_EXTRACT_PROMPT_B64="${{ secrets.PATTERN_EXTRACT_PROMPT_B64 }}" - PATTERN_EXTRACT_PROMPT="$(printf '%s' "$PATTERN_EXTRACT_PROMPT_B64" | base64 -d | tr -d '\r')" - export PATTERN_EXTRACT_PROMPT - - TYPE_SELECT_PROMPT_B64="${{ secrets.TYPE_SELECT_PROMPT_B64 }}" - TYPE_SELECT_PROMPT="$(printf '%s' "$TYPE_SELECT_PROMPT_B64" | base64 -d | tr -d '\r')" - export TYPE_SELECT_PROMPT - - TYPE_PROMPT_B64="${{ secrets.TYPE_PROMPT_B64 }}" - TYPE_PROMPT="$(printf '%s' "$TYPE_PROMPT_B64" | base64 -d | tr -d '\r')" - export TYPE_PROMPT - - REPAIR_TYPE_PROMPT_B64="${{ secrets.REPAIR_TYPE_PROMPT_B64 }}" - REPAIR_TYPE_PROMPT="$(printf '%s' "$REPAIR_TYPE_PROMPT_B64" | base64 -d | tr -d '\r')" - export REPAIR_TYPE_PROMPT - - ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64="${{ secrets.ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64 }}" - ASK_CHAT_ANSWER_SYSTEM_PROMPT="$(printf '%s' "$ASK_CHAT_ANSWER_SYSTEM_PROMPT_B64" | base64 -d | tr -d '\r')" - export ASK_CHAT_ANSWER_SYSTEM_PROMPT - - ASK_CHAT_ANSWER_USER_PROMPT_B64="${{ secrets.ASK_CHAT_ANSWER_USER_PROMPT_B64 }}" - ASK_CHAT_ANSWER_USER_PROMPT="$(printf '%s' "$ASK_CHAT_ANSWER_USER_PROMPT_B64" | base64 -d | tr -d '\r')" - export ASK_CHAT_ANSWER_USER_PROMPT - - NOTIFICATION_MESSAGES_B64="${{ secrets.NOTIFICATION_MESSAGES_B64 }}" - NOTIFICATION_MESSAGES="$(printf '%s' "$NOTIFICATION_MESSAGES_B64" | base64 -d | tr -d '\r')" - export NOTIFICATION_MESSAGES - - DB_URL="${{ secrets.DB_URL }}" \ - DB_USERNAME="${{ secrets.DB_USERNAME }}" \ - DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ - JWT_SECRET="${{ secrets.JWT_SECRET }}" \ - FRONTEND_DEV_URL="${{ secrets.FRONTEND_DEV_URL }}" \ - FRONTEND_DEV_URL_2="${{ secrets.FRONTEND_DEV_URL_2 }}" \ - FRONTEND_DEV_URL_3="${{ secrets.FRONTEND_DEV_URL_3 }}" \ - SWAGGER_SERVER_URL="${{ secrets.SWAGGER_SERVER_URL }}" \ - SWAGGER_ORIGIN_PUBLIC_IP="${{ secrets.SWAGGER_ORIGIN_PUBLIC_IP }}" \ - SWAGGER_ORIGIN_DOMAIN="${{ secrets.SWAGGER_ORIGIN_DOMAIN }}" \ - NAVER_CLIENT_ID="${{ secrets.NAVER_CLIENT_ID }}" \ - NAVER_CLIENT_SECRET="${{ secrets.NAVER_CLIENT_SECRET }}" \ - NAVER_REDIRECT_URI="${{ secrets.NAVER_REDIRECT_URI }}" \ - GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ - GOOGLE_ANDROID_CLIENT_ID="${{ secrets.GOOGLE_ANDROID_CLIENT_ID }}" \ - GOOGLE_IOS_CLIENT_ID="${{ secrets.GOOGLE_IOS_CLIENT_ID }}" \ - GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ - GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \ - KAKAO_CLIENT_ID="${{ secrets.KAKAO_CLIENT_ID }}" \ - KAKAO_CLIENT_SECRET="${{ secrets.KAKAO_CLIENT_SECRET }}" \ - KAKAO_REDIRECT_URI="${{ secrets.KAKAO_REDIRECT_URI }}" \ - KAKAO_APP_ID="${{ secrets.KAKAO_APP_ID }}" \ - APPLE_CLIENT_ID="${{ secrets.APPLE_CLIENT_ID }}" \ - APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" \ - APPLE_KEY_ID="${{ secrets.APPLE_KEY_ID }}" \ - APPLE_PRIVATE_KEY="${{ secrets.APPLE_PRIVATE_KEY }}" \ - IAM_ACCESS_KEY="${{ secrets.IAM_ACCESS_KEY }}" \ - IAM_SECRET_KEY="${{ secrets.IAM_SECRET_KEY }}" \ - BUCKET_NAME="${{ secrets.BUCKET_NAME }}" \ - PROFILE_IMAGE_BASE_URL="${{ secrets.PROFILE_IMAGE_BASE_URL }}" \ - CLOUDFRONT_KEY_PAIR_ID="${{ secrets.CLOUDFRONT_KEY_PAIR_ID }}" \ - CLOUDFRONT_PRIVATE_KEY="${{ secrets.CLOUDFRONT_PRIVATE_KEY }}" \ - KMS_KEY_ID="${{ secrets.KMS_KEY_ID }}" \ - MAIL_ADDRESS="${{ secrets.MAIL_ADDRESS }}" \ - MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \ - OPENAI_API_KEY="${{ secrets.OPENAI_API_KEY }}" \ - ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ - GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ - FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ - ADMIN_PAGE_PASSWORD='${{ secrets.ADMIN_PAGE_PASSWORD }}' \ - DEV_TEST_ACCOUNT_PASSWORD="${{ secrets.DEV_TEST_ACCOUNT_PASSWORD }}" \ + echo "πŸš€ μƒˆ 버전 μ‹€ν–‰..." nohup java -jar "$JAR_PATH" \ --spring.profiles.active=dev > app.log 2>&1 & echo "βœ… 배포 μ™„λ£Œ!" + + - name: 🧹 Cleanup deployment secrets + if: always() + run: | + set -euo pipefail + if [ -d deploy-secrets ]; then + rm -f -- deploy-secrets/* + rmdir -- deploy-secrets + fi diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java new file mode 100644 index 00000000..c48139de --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatWalletLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminAskChatWalletLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/ask-chat-wallet") +@RequiredArgsConstructor +public class AdminAskChatWalletLogController { + + private final AdminAskChatWalletLogQueryService adminAskChatWalletLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminAskChatWalletLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java new file mode 100644 index 00000000..7e39b2a5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminCrystalLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminCrystalLogQueryService; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/crystal") +@RequiredArgsConstructor +public class AdminCrystalLogController { + + private final AdminCrystalLogQueryService adminCrystalLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminCrystalLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java index 3a7b596d..7d59e51b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java @@ -30,4 +30,9 @@ public String adminVersionHistoryPage() { public String adminAskChatRagPage() { return "admin/ask-chat-rag"; } + + @GetMapping("/admin/tabs/logs") + public String adminLogsPage() { + return "admin/logs"; + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java new file mode 100644 index 00000000..db18aaf7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminReportGenerationLogController.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminLogSearchCondition; +import com.devkor.ifive.nadab.domain.admin.application.AdminReportGenerationLogQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/logs/report-generation") +@RequiredArgsConstructor +public class AdminReportGenerationLogController { + + private final AdminReportGenerationLogQueryService adminReportGenerationLogQueryService; + + @GetMapping + public ResponseEntity>> getLogs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) String email + ) { + AdminLogSearchCondition condition = AdminLogSearchCondition.of(page, size, nickname, email); + return ApiResponseEntity.ok(adminReportGenerationLogQueryService.getLogs(condition)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java new file mode 100644 index 00000000..533a343d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatWalletLogResponse.java @@ -0,0 +1,73 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogReason; +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLogStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "κ΄€λ¦¬μž Ask Chat λŒ€ν™”κΆŒ 둜그") +public record AdminAskChatWalletLogResponse( + @Schema(description = "둜그 ID", example = "1001") + Long id, + + @Schema(description = "둜그 μ‚¬μš©μž") + AdminLogUserResponse user, + + @Schema(description = "Ask Chat μ„Έμ…˜ ID", example = "3001") + Long sessionId, + + @Schema(description = "Ask Chat λ©”μ‹œμ§€ ID", example = "4001") + Long messageId, + + @Schema(description = "무료 λŒ€ν™”κΆŒ λ³€λ™λŸ‰", example = "-1") + int freeTurnDelta, + + @Schema(description = "유료 λŒ€ν™”κΆŒ λ³€λ™λŸ‰", example = "0") + int paidTurnDelta, + + @Schema(description = "변동 ν›„ 무료 λŒ€ν™”κΆŒ μž”μ•‘", example = "2") + int freeTurnBalanceAfter, + + @Schema(description = "변동 ν›„ 유료 λŒ€ν™”κΆŒ μž”μ•‘", example = "10") + int paidTurnBalanceAfter, + + @Schema(description = "변동 μ‚¬μœ ", example = "ANSWER_SUCCESS_CONSUME") + AskChatWalletLogReason reason, + + @Schema(description = "둜그 μƒνƒœ", example = "CONFIRMED") + AskChatWalletLogStatus status, + + @Schema(description = "μ°Έμ‘° μœ ν˜•", example = "ASK_CHAT_MESSAGE") + String refType, + + @Schema(description = "μ°Έμ‘° ID", example = "4001") + Long refId, + + @Schema(description = "λ©±λ“±μ„± ν‚€", example = "ask-chat-message-4001") + String idempotencyKey, + + @Schema(description = "둜그 생성 μ‹œκ°") + OffsetDateTime createdAt +) { + + public static AdminAskChatWalletLogResponse from(AskChatWalletLog log) { + return new AdminAskChatWalletLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getSession() == null ? null : log.getSession().getId(), + log.getMessage() == null ? null : log.getMessage().getId(), + log.getFreeTurnDelta(), + log.getPaidTurnDelta(), + log.getFreeTurnBalanceAfter(), + log.getPaidTurnBalanceAfter(), + log.getReason(), + log.getStatus(), + log.getRefType(), + log.getRefId(), + log.getIdempotencyKey(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java new file mode 100644 index 00000000..a0f69871 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminCrystalLogResponse.java @@ -0,0 +1,53 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "κ΄€λ¦¬μž ν¬λ¦¬μŠ€νƒˆ 둜그") +public record AdminCrystalLogResponse( + @Schema(description = "둜그 ID", example = "1001") + Long id, + + @Schema(description = "둜그 μ‚¬μš©μž") + AdminLogUserResponse user, + + @Schema(description = "ν¬λ¦¬μŠ€νƒˆ λ³€λ™λŸ‰", example = "-100") + long delta, + + @Schema(description = "변동 ν›„ ν¬λ¦¬μŠ€νƒˆ μž”μ•‘", example = "900") + long balanceAfter, + + @Schema(description = "변동 μ‚¬μœ ", example = "REPORT_GENERATE_MONTHLY") + CrystalLogReason reason, + + @Schema(description = "둜그 μƒνƒœ", example = "CONFIRMED") + CrystalLogStatus status, + + @Schema(description = "μ°Έμ‘° μœ ν˜•", example = "MONTHLY_REPORT") + String refType, + + @Schema(description = "μ°Έμ‘° ID", example = "2001") + Long refId, + + @Schema(description = "둜그 생성 μ‹œκ°") + OffsetDateTime createdAt +) { + + public static AdminCrystalLogResponse from(CrystalLog log) { + return new AdminCrystalLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getDelta(), + log.getBalanceAfter(), + log.getReason(), + log.getStatus(), + log.getRefType(), + log.getRefId(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java new file mode 100644 index 00000000..f4118096 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogPageResponse.java @@ -0,0 +1,46 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Schema(description = "κ΄€λ¦¬μž 둜그 νŽ˜μ΄μ§€ 응닡") +public record AdminLogPageResponse( + @Schema(description = "ν˜„μž¬ νŽ˜μ΄μ§€μ˜ 둜그 λͺ©λ‘") + List items, + + @Schema(description = "전체 둜그 개수", example = "43") + long totalCount, + + @Schema(description = "ν˜„μž¬ νŽ˜μ΄μ§€ 번호(1λΆ€ν„° μ‹œμž‘)", example = "1") + int currentPage, + + @Schema(description = "μš”μ²­ν•œ νŽ˜μ΄μ§€ 크기", example = "20") + int pageSize, + + @Schema(description = "전체 νŽ˜μ΄μ§€ 수", example = "3") + int totalPages, + + @Schema(description = "이전 νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€", example = "false") + boolean hasPrevious, + + @Schema(description = "λ‹€μŒ νŽ˜μ΄μ§€ 쑴재 μ—¬λΆ€", example = "true") + boolean hasNext +) { + + public static AdminLogPageResponse from(Page page) { + int currentPage = page.getNumber() + 1; + int totalPages = page.getTotalPages(); + + return new AdminLogPageResponse<>( + page.getContent(), + page.getTotalElements(), + currentPage, + page.getSize(), + totalPages, + currentPage > 1 && totalPages > 0, + totalPages > 0 && currentPage < totalPages + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java new file mode 100644 index 00000000..1b6c3861 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLogUserResponse.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "κ΄€λ¦¬μž 둜그 μ‚¬μš©μž 정보") +public record AdminLogUserResponse( + @Schema(description = "μ‚¬μš©μž ID", example = "101") + Long id, + + @Schema(description = "λ‹‰λ„€μž„", example = "nadab_user") + String nickname, + + @Schema(description = "이메일", example = "user@example.com") + String email +) { + + public static AdminLogUserResponse from(User user) { + if (user == null) { + return null; + } + + return new AdminLogUserResponse( + user.getId(), + user.getNickname(), + user.getEmail() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java new file mode 100644 index 00000000..8d1b8862 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminReportGenerationLogResponse.java @@ -0,0 +1,99 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "κ΄€λ¦¬μž 리포트 생성 둜그") +public record AdminReportGenerationLogResponse( + @Schema(description = "둜그 ID", example = "1001") + Long id, + + @Schema(description = "둜그 μ‚¬μš©μž") + AdminLogUserResponse user, + + @Schema(description = "리포트 μœ ν˜•", example = "MONTHLY_V2") + ReportGenerationType reportType, + + @Schema(description = "리포트 ID", example = "2001") + Long reportId, + + @Schema(description = "생성 단계", example = "MONTHLY_V2_TEXT_CONFIRM") + ReportGenerationStep step, + + @Schema(description = "둜그 μƒνƒœ", example = "SUCCEEDED") + ReportGenerationLogStatus status, + + @Schema(description = "LLM 제곡자", example = "OPENAI") + LlmProvider llmProvider, + + @Schema(description = "LLM λͺ¨λΈ", example = "GPT_4_O_MINI") + String llmModel, + + @Schema(description = "λ‚΄λΆ€ 였λ₯˜ μ½”λ“œ", example = "AI_NO_RESPONSE") + String errorCode, + + @Schema(description = "μ˜ˆμ™Έ 클래슀", example = "com.example.AiException") + String exceptionClass, + + @Schema(description = "μ™ΈλΆ€ 응닡 HTTP μƒνƒœ", example = "503") + Integer httpStatus, + + @Schema(description = "μ™ΈλΆ€ 였λ₯˜ μ½”λ“œ", example = "HTTP_503") + String externalErrorCode, + + @Schema(description = "처리 μ‹œκ°„(ms)", example = "1250") + Long elapsedMs, + + @Schema(description = "μž…λ ₯ 토큰 수", example = "1000") + Long inputTokens, + + @Schema(description = "좜λ ₯ 토큰 수", example = "500") + Long outputTokens, + + @Schema(description = "전체 토큰 수", example = "1500") + Long totalTokens, + + @Schema(description = "μΆ”λ‘  토큰 수", example = "300") + Long thinkingTokens, + + @Schema(description = "생성 μ‹œμž‘ μ‹œκ°") + OffsetDateTime startedAt, + + @Schema(description = "생성 μ’…λ£Œ μ‹œκ°") + OffsetDateTime endedAt, + + @Schema(description = "둜그 생성 μ‹œκ°") + OffsetDateTime createdAt +) { + + public static AdminReportGenerationLogResponse from(ReportGenerationLog log) { + return new AdminReportGenerationLogResponse( + log.getId(), + AdminLogUserResponse.from(log.getUser()), + log.getReportType(), + log.getReportId(), + log.getStep(), + log.getStatus(), + log.getLlmProvider(), + log.getLlmModel(), + log.getErrorCode(), + log.getExceptionClass(), + log.getHttpStatus(), + log.getExternalErrorCode(), + log.getElapsedMs(), + log.getInputTokens(), + log.getOutputTokens(), + log.getTotalTokens(), + log.getThinkingTokens(), + log.getStartedAt(), + log.getEndedAt(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java new file mode 100644 index 00000000..c8093d24 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminAskChatWalletLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatWalletLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminAskChatWalletLogQueryService { + + private final AskChatWalletLogRepository askChatWalletLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + askChatWalletLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminAskChatWalletLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java new file mode 100644 index 00000000..06c0c2cc --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminCrystalLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminCrystalLogResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCrystalLogQueryService { + + private final CrystalLogRepository crystalLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + crystalLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminCrystalLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java new file mode 100644 index 00000000..50214e60 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminLogSearchCondition.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.Locale; + +public record AdminLogSearchCondition( + int page, + int size, + String nickname, + String email +) { + + public static final int DEFAULT_PAGE = 1; + public static final int DEFAULT_SIZE = 20; + public static final int MAX_PAGE_SIZE = 50; + + public AdminLogSearchCondition { + if (page < 1 || size < 1 || size > MAX_PAGE_SIZE) { + throw new BadRequestException(ErrorCode.VALIDATION_FAILED); + } + + nickname = normalize(nickname); + email = normalize(email); + } + + public static AdminLogSearchCondition of( + int page, + int size, + String nickname, + String email + ) { + return new AdminLogSearchCondition(page, size, nickname, email); + } + + public Pageable toPageable() { + Sort sort = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); + return PageRequest.of(page - 1, size, sort); + } + + private static String normalize(String value) { + if (value == null) { + return null; + } + + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java new file mode 100644 index 00000000..c0dd4e4d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminReportGenerationLogQueryService.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLogPageResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminReportGenerationLogResponse; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminReportGenerationLogQueryService { + + private final ReportGenerationLogRepository reportGenerationLogRepository; + + public AdminLogPageResponse getLogs( + AdminLogSearchCondition condition + ) { + return AdminLogPageResponse.from( + reportGenerationLogRepository.findAllForAdmin( + condition.nickname(), + condition.email(), + condition.toPageable() + ) + .map(AdminReportGenerationLogResponse::from) + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java index c9ae2f0a..08cf29c5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java @@ -47,6 +47,7 @@ public class AskChatSessionController { ν™ˆ μ§„μž…λ§ŒμœΌλ‘œ μƒˆ μ±„νŒ… μ„Έμ…˜μ„ μƒμ„±ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.
μ‚¬μš©μžμ˜ λˆ„μ  λ‹΅λ³€ κ°œμˆ˜κ°€ 20개 이상인 κ²½μš°μ—λ§Œ 물어보기 ν™ˆμ„ μ‘°νšŒν•  수 μžˆμŠ΅λ‹ˆλ‹€.
μ‘λ‹΅μ—λŠ” 남은 λ©”μ‹œμ§€ 횟수, μ‚¬μš©μž λ‹‰λ„€μž„, 보유 ν¬λ¦¬μŠ€νƒˆ 수, μ˜ˆμ‹œ 질문 λͺ©λ‘μ„ ν¬ν•¨ν•©λ‹ˆλ‹€.
+ μ˜ˆμ‹œ μ§ˆλ¬Έμ€ μ‚¬μš©μžλ³„λ‘œ 10λΆ„ λ‹¨μœ„λ‘œ κ°±μ‹ λ˜λ©° 같은 μ‹œκ°„ κ΅¬κ°„μ—λŠ” λ™μΌν•˜κ²Œ μœ μ§€λ©λ‹ˆλ‹€.
νžˆμŠ€ν† λ¦¬ λͺ©λ‘μ€ 이 APIμ—μ„œ λ°˜ν™˜ν•˜μ§€ μ•ŠμœΌλ©°, 별도 νžˆμŠ€ν† λ¦¬ APIλ₯Ό μ‚¬μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€. """, security = @SecurityRequirement(name = "bearerAuth"), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java index ec516a0a..5d3a3542 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java @@ -15,7 +15,7 @@ public record AskChatHomeResponse( @Schema(description = "μ‚¬μš©μžκ°€ λ³΄μœ ν•œ ν¬λ¦¬μŠ€νƒˆ 개수", example = "100") long crystalBalance, - @Schema(description = "ν™ˆ 화면에 ν‘œμ‹œν•  μ˜ˆμ‹œ 질문 λͺ©λ‘. μ—¬λŸ¬ 주제 쀑 일뢀λ₯Ό 랜덀으둜 μ œκ³΅ν•©λ‹ˆλ‹€.") + @Schema(description = "ν™ˆ 화면에 ν‘œμ‹œν•  μ˜ˆμ‹œ 질문 λͺ©λ‘. μ‚¬μš©μžλ³„λ‘œ 10λΆ„ λ‹¨μœ„λ‘œ κ°±μ‹ λ©λ‹ˆλ‹€.") List sampleQuestions ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java index a55f3bd7..09b618e3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; +import com.devkor.ifive.nadab.domain.askchat.application.helper.AskChatSampleQuestionSelector; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; @@ -12,7 +13,6 @@ import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; -import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; @@ -23,13 +23,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -37,11 +32,11 @@ public class AskChatSessionService { public static final int MAX_TURN_COUNT = 15; public static final int MIN_ANSWER_COUNT_TO_USE_ASK_CHAT = 20; - private static final int HOME_SAMPLE_QUESTION_SIZE = 3; private final AskChatSessionRepository askChatSessionRepository; private final AskChatWalletRepository askChatWalletRepository; private final AskChatSampleQuestionRepository askChatSampleQuestionRepository; + private final AskChatSampleQuestionSelector askChatSampleQuestionSelector; private final UserWalletRepository userWalletRepository; private final UserRepository userRepository; private final AnswerEntryRepository answerEntryRepository; @@ -61,7 +56,7 @@ public AskChatHomeResponse getHome(Long userId) { askChatWallet.getTotalTurnBalance(), user.getNickname(), userWallet.getCrystalBalance(), - pickSampleQuestions() + selectSampleQuestions(userId) ); } @@ -92,27 +87,14 @@ private AskChatSession createSession(Long userId) { return askChatSessionRepository.save(AskChatSession.start(user)); } - private List pickSampleQuestions() { - Map> questionsByCategory = askChatSampleQuestionRepository - .findByActiveTrueOrderByDisplayOrderAsc() - .stream() - .collect(Collectors.groupingBy( - AskChatSampleQuestion::getInterestCode, - LinkedHashMap::new, - Collectors.toList() - )); - - List categories = new ArrayList<>(questionsByCategory.keySet()); - Collections.shuffle(categories); + private List selectSampleQuestions(Long userId) { + List sampleQuestions = askChatSampleQuestionRepository + .findByActiveTrueOrderByDisplayOrderAsc(); - return categories.stream() - .limit(HOME_SAMPLE_QUESTION_SIZE) - .map(category -> pickOne(questionsByCategory.get(category))) + return askChatSampleQuestionSelector + .select(userId, Instant.now(), sampleQuestions) + .stream() .map(AskChatSampleQuestionResponse::from) .toList(); } - - private AskChatSampleQuestion pickOne(List sampleQuestions) { - return sampleQuestions.get(ThreadLocalRandom.current().nextInt(sampleQuestions.size())); - } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java new file mode 100644 index 00000000..07246af4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java @@ -0,0 +1,94 @@ +package com.devkor.ifive.nadab.domain.askchat.application.helper; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +public class AskChatSampleQuestionSelector { + + private static final int SAMPLE_QUESTION_SIZE = 3; + private static final long ROTATION_INTERVAL_SECONDS = Duration.ofMinutes(10).toSeconds(); + private static final long CATEGORY_SALT = 0x9E3779B97F4A7C15L; + + public List select( + Long userId, + Instant now, + List sampleQuestions + ) { + Map> questionsByCategory = groupByCategory(sampleQuestions); + if (questionsByCategory.isEmpty()) { + return List.of(); + } + + long rotationSlot = Math.floorDiv(now.getEpochSecond(), ROTATION_INTERVAL_SECONDS); + List categories = orderedCategories(userId, questionsByCategory); + int categoryStartIndex = rotatedIndex(mix(userId), rotationSlot, categories.size()); + int resultSize = Math.min(SAMPLE_QUESTION_SIZE, categories.size()); + + List selectedQuestions = new ArrayList<>(resultSize); + for (int index = 0; index < resultSize; index++) { + InterestCode category = categories.get((categoryStartIndex + index) % categories.size()); + List categoryQuestions = questionsByCategory.get(category); + int questionIndex = rotatedIndex( + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L))), + rotationSlot, + categoryQuestions.size() + ); + selectedQuestions.add(categoryQuestions.get(questionIndex)); + } + + return List.copyOf(selectedQuestions); + } + + private Map> groupByCategory( + List sampleQuestions + ) { + Map> questionsByCategory = + new EnumMap<>(InterestCode.class); + + for (AskChatSampleQuestion sampleQuestion : sampleQuestions) { + questionsByCategory + .computeIfAbsent(sampleQuestion.getInterestCode(), ignored -> new ArrayList<>()) + .add(sampleQuestion); + } + + Comparator questionOrder = Comparator + .comparingInt(AskChatSampleQuestion::getDisplayOrder) + .thenComparing(AskChatSampleQuestion::getQuestion); + questionsByCategory.values().forEach(questions -> questions.sort(questionOrder)); + return questionsByCategory; + } + + private List orderedCategories( + Long userId, + Map> questionsByCategory + ) { + List categories = new ArrayList<>(questionsByCategory.keySet()); + categories.sort(Comparator + .comparingLong((InterestCode category) -> + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L)))) + .thenComparing(InterestCode::name)); + return categories; + } + + private int rotatedIndex(long base, long rotationSlot, int size) { + int baseIndex = Math.floorMod(base, size); + int slotOffset = Math.floorMod(rotationSlot, size); + return (baseIndex + slotOffset) % size; + } + + private long mix(long value) { + value = (value ^ (value >>> 33)) * 0xff51afd7ed558ccdl; + value = (value ^ (value >>> 33)) * 0xc4ceb9fe1a85ec53l; + return value ^ (value >>> 33); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java index b418fe44..79f66de5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatWalletLogRepository.java @@ -1,6 +1,8 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWalletLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -12,6 +14,31 @@ public interface AskChatWalletLogRepository extends JpaRepository findAllByUserIdOrderByCreatedAtDesc(Long userId); + @Query(value = """ + SELECT l + FROM AskChatWalletLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM AskChatWalletLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE AskChatWalletLog l diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java index 8609da43..5a76faef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java @@ -3,7 +3,11 @@ import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -17,4 +21,29 @@ List findAllByReportTypeAndReportIdOrderByCreatedAtDesc( List findAllByUserIdOrderByCreatedAtDesc(Long userId); List findAllByStatusOrderByCreatedAtDesc(ReportGenerationLogStatus status); + + @Query(value = """ + SELECT l + FROM ReportGenerationLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM ReportGenerationLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java index 435c4b25..307bf53c 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; @@ -26,33 +27,34 @@ public class DailyStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int DAILY_CHART_SIZE = 7; - public DailyStatsViewModel getDailyStatsLast7Days() { + public DailyStatsViewModel getDailyStats(LocalDate selectedDate) { LocalDate today = TodayDateTimeProvider.getTodayDate(); - LocalDate startDate = today.minusDays(6); + LocalDate startDate = selectedDate.minusDays(DAILY_CHART_SIZE - 1L); // 라벨 7개 κ³ μ • 생성 List days = new ArrayList<>(); - for (int i = 0; i < 7; i++) days.add(startDate.plusDays(i)); + for (int i = 0; i < DAILY_CHART_SIZE; i++) days.add(startDate.plusDays(i)); List labels = days.stream().map(LocalDate::toString).toList(); // 1) κ°€μž…μž 수 Map signupMap = new HashMap<>(); - for (Object[] row : repo.findSignupCountsLast7Days(startDate, today)) { + for (Object[] row : repo.findSignupCountsLast7Days(startDate, selectedDate)) { DateCountDto dto = DailyStatsRepository.toDateCountDto(row); signupMap.put(dto.date(), dto.count()); } // 2) ν• λ‹Ή 질문 수 Map assignedMap = new HashMap<>(); - for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, selectedDate)) { assignedMap.put(dto.date(), dto.count()); } // 3) COMPLETED 리포트 수 Map completedMap = new HashMap<>(); - for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, selectedDate)) { completedMap.put(dto.date(), dto.count()); } @@ -71,17 +73,26 @@ public DailyStatsViewModel getDailyStatsLast7Days() { PeakMetric.DAU, days, completedCounts, today ); - long sharedNow = repo.countSharedDailyReportsNow(); + int selectedIndex = days.size() - 1; + long sharedDailyReportCount = repo.countSharedDailyReports(selectedDate); + DailyPeriodStatsViewModel selectedPeriod = new DailyPeriodStatsViewModel( + selectedDate.toString(), + selectedDate.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + sharedDailyReportCount + ); return new DailyStatsViewModel( labels, signupCounts, assignedCounts, completedCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, dauPeak, - sharedNow, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java index c65a392a..d83208b5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; @@ -34,13 +35,13 @@ public class MonthlyStatsService { DateTimeFormatter.ofPattern("yyyy-MM"); private static final int MONTHLY_CHART_SIZE = 5; - public MonthlyStatsViewModel getMonthlyStatsLast5Months() { + public MonthlyStatsViewModel getMonthlyStats(YearMonth selectedMonth) { LocalDate today = TodayDateTimeProvider.getTodayDate(); YearMonth currentMonth = YearMonth.from(today); - YearMonth startMonth = currentMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); + YearMonth startMonth = selectedMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); LocalDate startDate = startMonth.atDay(1); - LocalDate endDateInclusive = currentMonth.atEndOfMonth(); + LocalDate endDateInclusive = selectedMonth.atEndOfMonth(); List months = new ArrayList<>(); for (int i = 0; i < MONTHLY_CHART_SIZE; i++) { @@ -99,6 +100,18 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); long inProgressTotalNow = inProgressV1Now + inProgressV2Now; + int selectedIndex = months.size() - 1; + MonthlyPeriodStatsViewModel selectedPeriod = new MonthlyPeriodStatsViewModel( + selectedMonth.toString(), + selectedMonth.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedV1Counts.get(selectedIndex), + completedV2Counts.get(selectedIndex), + completedTotalCounts.get(selectedIndex), + mauCounts.get(selectedIndex) + ); return new MonthlyStatsViewModel( labels, @@ -109,6 +122,7 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { completedV2Counts, completedTotalCounts, mauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java index f88ee9d7..b598b151 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java @@ -1,15 +1,24 @@ package com.devkor.ifive.nadab.domain.stats.application; -import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestSeriesViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.TypeStatsRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -20,22 +29,70 @@ public class TypeStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int RECENT_DAYS = 7; public TypeStatsViewModel getTypeStats() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate startDate = today.minusDays(RECENT_DAYS - 1L); + long inProgressTypeReportCount = repo.countInProgressTypeReportsNow(); - List completedByInterest = repo.countCompletedTypeReportsByInterest(); + List activeCompletedByInterest = + repo.countActiveCompletedTypeReportsByInterest(); + List completedHistoryByInterest = + repo.countCompletedTypeReportHistoryByInterest(); + List completedByDateAndInterest = + repo.countCompletedTypeReportsByDateAndInterest(startDate, today); + + Map activeCompletedCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : activeCompletedByInterest) { + activeCompletedCountMap.put(dto.interestCode(), dto.count()); + } - List interestLabels = completedByInterest.stream() - .map(LabelCountDto::label) + Map completedHistoryCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : completedHistoryByInterest) { + completedHistoryCountMap.put(dto.interestCode(), dto.count()); + } + + List interests = List.of(InterestCode.values()); + List interestLabels = interests.stream() + .map(InterestCode::displayNameKo) + .toList(); + List activeCompletedTypeReportCounts = interests.stream() + .map(interest -> activeCompletedCountMap.getOrDefault(interest, 0L)) .toList(); - List completedTypeReportCounts = completedByInterest.stream() - .map(LabelCountDto::count) + List cumulativeCompletedTypeReportCounts = interests.stream() + .map(interest -> completedHistoryCountMap.getOrDefault(interest, 0L)) + .toList(); + + List recentDates = new ArrayList<>(RECENT_DAYS); + for (int i = 0; i < RECENT_DAYS; i++) { + recentDates.add(startDate.plusDays(i)); + } + + Map> dailyCountMap = new HashMap<>(); + for (TypeReportDateInterestCountDto dto : completedByDateAndInterest) { + dailyCountMap.computeIfAbsent(dto.date(), ignored -> new EnumMap<>(InterestCode.class)) + .put(dto.interestCode(), dto.count()); + } + + List completedTypeReportSeries = interests.stream() + .map(interest -> new TypeReportInterestSeriesViewModel( + interest.name(), + interest.displayNameKo(), + recentDates.stream() + .map(date -> dailyCountMap.getOrDefault(date, Map.of()) + .getOrDefault(interest, 0L)) + .toList() + )) .toList(); return new TypeStatsViewModel( inProgressTypeReportCount, interestLabels, - completedTypeReportCounts, + activeCompletedTypeReportCounts, + cumulativeCompletedTypeReportCounts, + recentDates.stream().map(LocalDate::toString).toList(), + completedTypeReportSeries, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java index 5b67a51a..c0d77222 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -33,15 +35,17 @@ public class WeeklyStatsService { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter WEEK_LABEL_FMT = DateTimeFormatter.ofPattern("MM-dd"); + private static final int WEEKLY_CHART_SIZE = 5; - public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { + public WeeklyStatsViewModel getWeeklyStats(LocalDate selectedWeek) { LocalDate today = TodayDateTimeProvider.getTodayDate(); LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate startWeekStart = currentWeekStart.minusWeeks(6); - LocalDate endDateInclusive = currentWeekStart.plusDays(6); + LocalDate selectedWeekStart = selectedWeek.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate startWeekStart = selectedWeekStart.minusWeeks(WEEKLY_CHART_SIZE - 1L); + LocalDate endDateInclusive = selectedWeekStart.plusDays(6); List weekStarts = new ArrayList<>(); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < WEEKLY_CHART_SIZE; i++) { weekStarts.add(startWeekStart.plusWeeks(i)); } @@ -89,6 +93,16 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { ); long inProgressNow = repo.countInProgressWeeklyReportsNow(); + int selectedIndex = weekStarts.size() - 1; + WeeklyPeriodStatsViewModel selectedPeriod = new WeeklyPeriodStatsViewModel( + StatsPeriodResolver.formatIsoWeek(selectedWeekStart), + selectedWeekStart + " ~ " + selectedWeekStart.plusDays(6), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + wauCounts.get(selectedIndex) + ); return new WeeklyStatsViewModel( labels, @@ -97,6 +111,7 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { completedDailyCounts, completedCounts, wauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java new file mode 100644 index 00000000..1aa33def --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java @@ -0,0 +1,102 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.IsoFields; +import java.time.temporal.TemporalAdjusters; +import java.util.Locale; + +public final class StatsPeriodResolver { + + private static final DateTimeFormatter ISO_WEEK_FORMATTER = new DateTimeFormatterBuilder() + .appendValue(IsoFields.WEEK_BASED_YEAR, 4) + .appendLiteral("-W") + .appendValue(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2) + .toFormatter(Locale.ROOT); + + private StatsPeriodResolver() {} + + public static LocalDate resolveDaily(String value) { + return resolveDaily(value, TodayDateTimeProvider.getTodayDate()); + } + + public static LocalDate resolveWeekly(String value) { + return resolveWeekly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static YearMonth resolveMonthly(String value) { + return resolveMonthly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static String formatIsoWeek(LocalDate weekStart) { + return ISO_WEEK_FORMATTER.format( + weekStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + ); + } + + static LocalDate resolveDaily(String value, LocalDate today) { + LocalDate selectedDate = isBlank(value) ? today : parseDate(value); + if (selectedDate.isAfter(today)) { + throw invalidPeriod(); + } + return selectedDate; + } + + static LocalDate resolveWeekly(String value, LocalDate today) { + LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate selectedWeekStart = isBlank(value) ? currentWeekStart : parseWeekStart(value); + if (selectedWeekStart.isAfter(currentWeekStart)) { + throw invalidPeriod(); + } + return selectedWeekStart; + } + + static YearMonth resolveMonthly(String value, LocalDate today) { + YearMonth currentMonth = YearMonth.from(today); + YearMonth selectedMonth = isBlank(value) ? currentMonth : parseMonth(value); + if (selectedMonth.isAfter(currentMonth)) { + throw invalidPeriod(); + } + return selectedMonth; + } + + private static LocalDate parseDate(String value) { + try { + return LocalDate.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static LocalDate parseWeekStart(String value) { + try { + return LocalDate.parse(value + "-1", DateTimeFormatter.ISO_WEEK_DATE); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static YearMonth parseMonth(String value) { + try { + return YearMonth.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static BadRequestException invalidPeriod() { + return new BadRequestException(ErrorCode.VALIDATION_FAILED); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index 2e5a18ed..2a76748f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; @@ -16,6 +17,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.YearMonth; @Controller @RequiredArgsConstructor @@ -29,25 +34,37 @@ public class StatsController { private final WithdrawalStatsService withdrawalStatsService; - @GetMapping("stats/daily") - public String dailyStats(Model model) { - DailyStatsViewModel vm = dailyStatsService.getDailyStatsLast7Days(); + @GetMapping("/stats/daily") + public String dailyStats( + @RequestParam(required = false) String date, + Model model + ) { + LocalDate selectedDate = StatsPeriodResolver.resolveDaily(date); + DailyStatsViewModel vm = dailyStatsService.getDailyStats(selectedDate); model.addAttribute("vm", vm); model.addAttribute("activeTab", "daily"); return "stats/daily"; } @GetMapping("/stats/weekly") - public String weeklyStats(Model model) { - WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStatsLast7Weeks(); + public String weeklyStats( + @RequestParam(required = false) String week, + Model model + ) { + LocalDate selectedWeekStart = StatsPeriodResolver.resolveWeekly(week); + WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStats(selectedWeekStart); model.addAttribute("vm", vm); model.addAttribute("activeTab", "weekly"); return "stats/weekly"; } @GetMapping("/stats/monthly") - public String monthlyStats(Model model) { - MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStatsLast5Months(); + public String monthlyStats( + @RequestParam(required = false) String month, + Model model + ) { + YearMonth selectedMonth = StatsPeriodResolver.resolveMonthly(month); + MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStats(selectedMonth); model.addAttribute("vm", vm); model.addAttribute("activeTab", "monthly"); return "stats/monthly"; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java new file mode 100644 index 00000000..269f29bf --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.daily; + +public record DailyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long dauCount, + long sharedDailyReportCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java index 00776dfc..07f801f2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java @@ -9,9 +9,9 @@ public record DailyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, + DailyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel dauPeak, - long sharedDailyReportCount, String refreshedAt // "2026-02-27 21:34:12" ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java new file mode 100644 index 00000000..9091d68d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.monthly; + +public record MonthlyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedMonthlyReportV1Count, + long completedMonthlyReportV2Count, + long completedMonthlyReportTotalCount, + long mauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java index 0abca917..fe3e9390 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java @@ -13,6 +13,7 @@ public record MonthlyStatsViewModel( List completedMonthlyReportV2Counts, List completedMonthlyReportTotalCounts, List mauCounts, + MonthlyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java new file mode 100644 index 00000000..5d6ee77f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.LocalDate; + +public record TypeReportDateInterestCountDto( + LocalDate date, + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java new file mode 100644 index 00000000..81f5eccd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +public record TypeReportInterestCountDto( + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java new file mode 100644 index 00000000..3fcf1171 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import java.util.List; + +public record TypeReportInterestSeriesViewModel( + String interestCode, + String interestLabel, + List counts +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java index c2e6042f..89ab86e6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java @@ -5,6 +5,9 @@ public record TypeStatsViewModel( long inProgressTypeReportCount, List interestLabels, - List completedTypeReportCounts, + List activeCompletedTypeReportCounts, + List cumulativeCompletedTypeReportCounts, + List recentDateLabels, + List completedTypeReportSeries, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java new file mode 100644 index 00000000..ecc9951b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.weekly; + +public record WeeklyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedWeeklyReportCount, + long wauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java index 5e79d50f..41a30b52 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java @@ -11,6 +11,7 @@ public record WeeklyStatsViewModel( List completedDailyReportCounts, List completedWeeklyReportCounts, List wauCounts, + WeeklyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java index 756a5f35..419011eb 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -1,7 +1,6 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; -import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -76,21 +75,15 @@ public List findCompletedDailyReportCountsLast7Days(LocalDate star .getResultList(); } - /** - * ν˜„μž¬ 곡유 쀑(isShared=true) daily_reports 개수 - * - 보톡 COMPLETED만 곡유 μ˜λ―Έκ°€ μžˆμœΌλ‹ˆ status도 κ±°λŠ” κ±Έ μΆ”μ²œ - */ - public long countSharedDailyReportsNow() { - LocalDate today = TodayDateTimeProvider.getTodayDate(); - + public long countSharedDailyReports(LocalDate date) { return em.createQuery(""" select count(dr.id) from DailyReport dr - where dr.date = :today + where dr.date = :date and dr.isShared = true and dr.status = 'COMPLETED' """, Long.class) - .setParameter("today", today) + .setParameter("date", date) .getSingleResult(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java index 22895953..dc96a2fc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; -import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; @Repository @@ -23,19 +25,53 @@ select count(tr.id) .getSingleResult(); } - public List countCompletedTypeReportsByInterest() { + public List countActiveCompletedTypeReportsByInterest() { return em.createQuery(""" - select new com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto( - cast(i.code as string), + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, count(tr.id) ) - from Interest i - left join TypeReport tr on tr.interestCode = i.code - and tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED - and tr.deletedAt is null - group by i.code - order by i.code - """, LabelCountDto.class) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.deletedAt is null + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportHistoryByInterest() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportsByDateAndInterest( + LocalDate startDate, + LocalDate endDateInclusive + ) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto( + tr.date, + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.date between :startDate and :endDate + group by tr.date, tr.interestCode + order by tr.date, tr.interestCode + """, TypeReportDateInterestCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) .getResultList(); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java index b8e226d6..4f93ef11 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java @@ -43,7 +43,7 @@ public class TypeReportController { - **PREFERENCE** : μ·¨ν–₯ - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : μ‚¬λž‘ - **VALUES** : κ°€μΉ˜κ΄€ """, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java index e075bc4d..c4e48c16 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.typereport.application; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportGenerationRequestedEventDto; import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReserveResultDto; @@ -114,6 +115,16 @@ public void confirmType( String persona2Title, String persona2Content ) { + validateContentFormat( + typeAnalysis, + typeAnalysisContent, + emotionSummaryContent, + persona1Title, + persona1Content, + persona2Title, + persona2Content + ); + TypeTextContent normalizedTypeAnalysisContent = typeAnalysisContent.normalized(); TypeTextContent normalizedEmotionSummaryContent = emotionSummaryContent.normalized(); TypeEmotionStatsContent normalizedEmotionStats = emotionStats.normalized(); @@ -156,6 +167,29 @@ public void confirmType( crystalLogRepository.markConfirmed(logId); } + private void validateContentFormat( + String typeAnalysis, + TypeTextContent typeAnalysisContent, + TypeTextContent emotionSummaryContent, + String persona1Title, + String persona1Content, + String persona2Title, + String persona2Content + ) { + if (TypeReportContentFormat.containsUnsupportedMarkup(typeAnalysis) + || TypeReportContentFormat.containsUnsupportedMarkup(typeAnalysisContent) + || TypeReportContentFormat.containsUnsupportedMarkup(emotionSummaryContent)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + + if (TypeReportContentFormat.containsUnsupportedMarkup(persona1Title) + || TypeReportContentFormat.containsUnsupportedMarkup(persona1Content) + || TypeReportContentFormat.containsUnsupportedMarkup(persona2Title) + || TypeReportContentFormat.containsUnsupportedMarkup(persona2Content)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } + } + public void failAndRefundType(Long userId, Long reportId, Long logId) { typeReportRepository.markFailed(reportId, TypeReportStatus.FAILED); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java index 20037a31..7174dc28 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.typereport.application.mapper; import com.devkor.ifive.nadab.domain.typereport.api.dto.response.TypeReportResponse; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.entity.AnalysisType; import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReport; @@ -19,10 +20,10 @@ public static TypeReportResponse toResponse(TypeReport report, AnalysisType anal report.getTypeAnalysisContent(), report.getEmotionSummaryContent(), report.getEmotionStats(), - report.getPersona1Title(), - report.getPersona1Content(), - report.getPersona2Title(), - report.getPersona2Content(), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona1Title()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona1Content()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona2Title()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona2Content()), typeImageUrl // μœ„μ—μ„œ μ²˜λ¦¬ν–ˆμœΌλ―€λ‘œ κ·ΈλŒ€λ‘œ 전달 (nullable) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java new file mode 100644 index 00000000..c02ee466 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java @@ -0,0 +1,36 @@ +package com.devkor.ifive.nadab.domain.typereport.core.content; + +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; + +import java.util.regex.Pattern; + +public final class TypeReportContentFormat { + + private static final Pattern UNSUPPORTED_MARKS_TAG = + Pattern.compile("<\\s*/?\\s*marks?\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern LEGACY_MARKS_TAG = Pattern.compile( + "<\\s*marks?\\s+text=\"([^\"]*)\"\\s+marks=\\[[^\\]]*]\\s*/?>", + Pattern.CASE_INSENSITIVE + ); + + private TypeReportContentFormat() {} + + public static boolean containsUnsupportedMarkup(String text) { + return text != null && UNSUPPORTED_MARKS_TAG.matcher(text).find(); + } + + public static String sanitizeLegacyMarkup(String text) { + return text == null ? null : LEGACY_MARKS_TAG.matcher(text).replaceAll("$1"); + } + + public static boolean containsUnsupportedMarkup(TypeTextContent content) { + if (content == null || content.styledText() == null || content.styledText().segments() == null) { + return false; + } + + return content.styledText().segments().stream() + .filter(segment -> segment != null) + .map(Segment::text) + .anyMatch(TypeReportContentFormat::containsUnsupportedMarkup); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java index 54089374..0c2ee77f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java @@ -2,6 +2,7 @@ import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.domain.typereport.core.dto.AnalysisTypeCandidateDto; @@ -157,6 +158,12 @@ private void validate(TypeReportContentDto dto, String expectedAnalysisTypeCode) throw new AiResponseParseException(ErrorCode.TYPE_REPORT_JSON_MISSING_FIELDS); } + if (TypeReportContentFormat.containsUnsupportedMarkup(dto.typeAnalysis()) + || TypeReportContentFormat.containsUnsupportedMarkup(dto.typeAnalysisContent()) + || TypeReportContentFormat.containsUnsupportedMarkup(dto.emotionSummaryContent())) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + List personas = dto.personas(); if (personas == null || personas.size() != PERSONA_COUNT) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONA_COUNT_INVALID); @@ -172,6 +179,11 @@ private void validate(TypeReportContentDto dto, String expectedAnalysisTypeCode) if (contentLen < PERSONA_CONTENT_MIN || contentLen > PERSONA_CONTENT_MAX) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONA_CONTENT_LENGTH_INVALID); } + + if (TypeReportContentFormat.containsUnsupportedMarkup(p.title()) + || TypeReportContentFormat.containsUnsupportedMarkup(p.content())) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java index 8c8009a4..43782e25 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java @@ -13,6 +13,7 @@ import com.devkor.ifive.nadab.global.shared.reportcontent.Mark; import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonParseException; @@ -140,10 +141,14 @@ private LlmGenerationResult enforceLength(JsonNode raw) { boolean badP1 = false, badP2 = false; if (personasArr.size() >= 1) { - badP1 = isOutOfRange(getPersonaContent(personasArr.get(0)).length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT); + String personaContent = getPersonaContent(personasArr.get(0)); + badP1 = isOutOfRange(personaContent.length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT) + || TypeReportContentFormat.containsUnsupportedMarkup(personaContent); } if (personasArr.size() >= 2) { - badP2 = isOutOfRange(getPersonaContent(personasArr.get(1)).length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT); + String personaContent = getPersonaContent(personasArr.get(1)); + badP2 = isOutOfRange(personaContent.length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT) + || TypeReportContentFormat.containsUnsupportedMarkup(personaContent); } if (!badTA && !badP1 && !badP2) return new LlmGenerationResult<>(root, LlmTokenUsage.empty()); @@ -207,6 +212,9 @@ private LlmGenerationResult enforceLength(JsonNode raw) { : ErrorCode.TYPE_REPORT_REWRITE_PERSONA_2_LENGTH_INVALID ); } + if (TypeReportContentFormat.containsUnsupportedMarkup(c)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } } return new LlmGenerationResult<>(root, tokenUsage); @@ -257,6 +265,10 @@ private void validateStyledText(StyledText styledText, boolean isTypeAnalysis) { String text = segment.text(); + if (TypeReportContentFormat.containsUnsupportedMarkup(text)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + if (!isTypeAnalysis && text.contains("\n") || text.contains("\r")) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java index e80c022d..e2ae9eb2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java @@ -241,7 +241,7 @@ public ResponseEntity> checkNickname( - **PREFERENCE** : μ·¨ν–₯ - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : μ‚¬λž‘ - **VALUES** : κ°€μΉ˜κ΄€ """, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java index f9566376..81d566ed 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java @@ -28,7 +28,7 @@ public String displayNameKo() { case PREFERENCE -> "μ·¨ν–₯"; case EMOTION -> "감정"; case ROUTINE -> "루틴"; - case RELATIONSHIP -> "인간관계"; + case RELATIONSHIP -> "관계"; case LOVE -> "μ‚¬λž‘"; case VALUES -> "κ°€μΉ˜κ΄€"; }; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java index a127fe39..ca3e9d30 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/repository/CrystalLogRepository.java @@ -1,6 +1,8 @@ package com.devkor.ifive.nadab.domain.wallet.core.repository; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,6 +10,31 @@ public interface CrystalLogRepository extends JpaRepository { + @Query(value = """ + SELECT l + FROM CrystalLog l + LEFT JOIN FETCH l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + ORDER BY l.createdAt DESC, l.id DESC + """, + countQuery = """ + SELECT COUNT(l) + FROM CrystalLog l + LEFT JOIN l.user u + WHERE (COALESCE(:nickname, '') = '' + OR LOWER(u.nickname) LIKE CONCAT('%', COALESCE(:nickname, ''), '%')) + AND (COALESCE(:email, '') = '' + OR LOWER(u.email) LIKE CONCAT('%', COALESCE(:email, ''), '%')) + """) + Page findAllForAdmin( + @Param("nickname") String nickname, + @Param("email") String email, + Pageable pageable + ); + @Modifying @Query("UPDATE CrystalLog l SET l.status = com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogStatus.CONFIRMED WHERE l.id = :logId") int markConfirmed(@Param("logId") Long logId); diff --git a/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql new file mode 100644 index 00000000..dcd74e86 --- /dev/null +++ b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_type_reports_completed_date_interest + ON type_reports (date, interest_code) + WHERE status = 'COMPLETED'; diff --git a/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql b/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql new file mode 100644 index 00000000..84b71bc2 --- /dev/null +++ b/src/main/resources/db/migration/V20260820_1300__IS_add_admin_log_created_at_indexes.sql @@ -0,0 +1,8 @@ +CREATE INDEX idx_report_generation_logs_created_id + ON report_generation_logs(created_at DESC, id DESC); + +CREATE INDEX idx_crystal_logs_created_id + ON crystal_logs(created_at DESC, id DESC); + +CREATE INDEX idx_ask_chat_wallet_logs_created_id + ON ask_chat_wallet_logs(created_at DESC, id DESC); diff --git a/src/main/resources/static/css/stats-period.css b/src/main/resources/static/css/stats-period.css new file mode 100644 index 00000000..6c1a2715 --- /dev/null +++ b/src/main/resources/static/css/stats-period.css @@ -0,0 +1,208 @@ +.period-panel { + margin-bottom: 20px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + animation: fadeUp .35s ease both; +} + +.period-panel-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px 24px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} + +.period-panel-eyebrow { + margin-bottom: 5px; + color: var(--accent); + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.period-panel-title { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.25rem; + font-weight: 500; +} + +.period-picker { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.period-picker-field { + display: grid; + gap: 5px; +} + +.period-picker-label { + color: var(--text-2); + font-size: .68rem; + font-weight: 600; +} + +.period-picker-input { + min-height: 36px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-2); + color: var(--text-1); + color-scheme: dark; + font-family: 'DM Mono', monospace; + font-size: .76rem; + transition: border-color .2s, box-shadow .2s; +} + +.period-picker-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 143, 255, .12); +} + +.period-picker-submit, +.period-picker-current { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + padding: 7px 13px; + border-radius: var(--radius-sm); + font-family: 'Syne', sans-serif; + font-size: .74rem; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.period-picker-submit { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; +} + +.period-picker-current { + border: 1px solid var(--border); + background: transparent; + color: var(--text-2); + transition: border-color .2s, color .2s, background .2s; +} + +.period-picker-current:hover { + border-color: var(--accent); + background: rgba(108, 143, 255, .08); + color: var(--accent); +} + +.period-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + background: var(--border); +} + +.period-summary-item { + min-width: 0; + padding: 17px 20px 18px; + background: var(--surface); +} + +.period-summary-label { + margin-bottom: 7px; + color: var(--text-2); + font-size: .7rem; + font-weight: 600; + line-height: 1.35; +} + +.period-summary-value { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.55rem; + font-weight: 500; + line-height: 1; +} + +.period-summary-breakdown { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + margin-top: 8px; + color: var(--text-2); + font-family: 'DM Mono', monospace; + font-size: .66rem; +} + +.period-summary-breakdown strong { + margin-left: 4px; + color: var(--text-1); + font-weight: 500; +} + +@media (max-width: 680px) { + .page-header { + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .header-right { + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px 12px; + } + + .tabs { + width: 100%; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab-link { + flex: 0 0 auto; + padding: 7px 12px; + font-size: .76rem; + } + + .period-panel-header { + align-items: stretch; + flex-direction: column; + } + + .period-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .period-picker-input { + width: 100%; + } + + .period-summary-item:last-child:nth-child(odd) { + grid-column: 1 / -1; + } +} + +@media (max-width: 440px) { + .period-picker { + grid-template-columns: 1fr 1fr; + } + + .period-picker-field { + grid-column: 1 / -1; + } +} diff --git a/src/main/resources/templates/admin/ask-chat-rag.html b/src/main/resources/templates/admin/ask-chat-rag.html index 3482db00..bc5d436a 100644 --- a/src/main/resources/templates/admin/ask-chat-rag.html +++ b/src/main/resources/templates/admin/ask-chat-rag.html @@ -224,6 +224,7 @@

Admin Console

diff --git a/src/main/resources/templates/admin/logs.html b/src/main/resources/templates/admin/logs.html new file mode 100644 index 00000000..b64ce3b6 --- /dev/null +++ b/src/main/resources/templates/admin/logs.html @@ -0,0 +1,662 @@ + + + + + + NADAB Admin Logs + + + +
+
+

Admin Console

+ +
+ + + +
+
+
+

Main Logs

+

Browse the latest records and filter them by nickname or email.

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

Loading...

+
+ + +
+
+ + + + diff --git a/src/main/resources/templates/admin/version-history.html b/src/main/resources/templates/admin/version-history.html index 39bbe632..93b845e7 100644 --- a/src/main/resources/templates/admin/version-history.html +++ b/src/main/resources/templates/admin/version-history.html @@ -279,6 +279,7 @@

Admin Console

diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html index c534a66a..3733398a 100644 --- a/src/main/resources/templates/admin/version.html +++ b/src/main/resources/templates/admin/version.html @@ -305,6 +305,7 @@

Admin Console

diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index db8502b5..e34109c6 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -244,6 +244,7 @@ .chart-card:nth-child(3) { animation-delay: .15s; } + @@ -289,14 +290,6 @@ th:href="@{/stats/total}">전체
- -
-
-
ν˜„μž¬ 곡유 쀑인 일간 리포트
-
0
-
-
-
@@ -348,6 +341,8 @@
+
+