diff --git a/.github/workflows/deploy-dev.yml.disabled b/.github/workflows/deploy-dev.yml.disabled deleted file mode 100644 index a8a8ecb8..00000000 --- a/.github/workflows/deploy-dev.yml.disabled +++ /dev/null @@ -1,64 +0,0 @@ -name: Java CI/CD with Gradle (Dev Server) - -on: - push: - branches: [ "dev" ] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test - - # // 1. JAR 파일만 전송 준비 (환경변수는 서버에서 직접 생성하는게 더 깔끔해) - - name: Prepare deployment files - run: | - mkdir -p deploy - cp build/libs/*-SNAPSHOT.jar deploy/ - - # // 2. JAR 파일 EC2로 전송 - - name: Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "deploy/*" - target: "~/dev-server" - strip_components: 1 - - # // 3. EC2 서버에서 실행 스크립트 - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - # 1. 기존 8081 프로세스 종료 - fuser -k 8081/tcp || true - - cd ~/dev-server - - # 2. .env 파일 생성 - cat <<'EOF' > .env - ${{ secrets.ENV_VARIABLES }} - EOF - - # 3. 환경 변수 로드 및 메모리 제한 걸어서 실행 - # // 1. set -a로 .env 로드, -Xmx256m으로 메모리 방어 - set -a; source .env; set +a - nohup java -Xmx256m -Dserver.port=8081 -jar *-SNAPSHOT.jar > dev-app.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy-main.yml.disabled b/.github/workflows/deploy-main.yml.disabled deleted file mode 100644 index ea37df10..00000000 --- a/.github/workflows/deploy-main.yml.disabled +++ /dev/null @@ -1,66 +0,0 @@ -name: Java CI/CD with Gradle (Main Server) - -on: - push: - branches: [ "main" ] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'gradle' - - - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test - - # // 1. 전송용 폴더에 JAR 파일만 준비 (환경변수는 보안상 서버에서 직접 생성) - - name: Prepare deployment files - run: | - mkdir -p deploy - cp build/libs/*-SNAPSHOT.jar deploy/ - - # // 2. 메인 서버 폴더로 JAR 전송 - - name: Copy files to EC2 - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - source: "deploy/*" - target: "~/main-server" - strip_components: 1 - - # // 3. 운영 서버 실행 스크립트 - - name: Deploy to EC2 - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - # 1. 기존 8080 프로세스 종료 - fuser -k 8080/tcp || true - - # 2. 운영 서버 폴더 이동 - cd ~/main-server - - # 3. .env 파일 생성 (운영 전용 Secrets 사용) - # // 1. EOF를 써서 특수문자 깨짐 없이 안전하게 저장 - cat <<'EOF' > .env - ${{ secrets.ENV_VARIABLES }} - EOF - - # 4. 환경 변수 로드 및 운영 서버 실행 - # // 2. 운영은 400MB 제한으로 안정성 확보 - set -a; source .env; set +a - nohup java -Xmx400m -Dserver.port=8080 -jar *-SNAPSHOT.jar > server.log 2>&1 & \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1c10460..d4b0db9c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,9 +2,9 @@ name: Java CI for Picke on: push: - branches: [ "develop", "main" ] + branches: [ "dev", "main" ] pull_request: - branches: [ "develop", "main" ] + branches: [ "dev", "main" ] jobs: build: @@ -21,6 +21,9 @@ jobs: distribution: 'temurin' cache: gradle # 1. 빌드 속도를 획기적으로 줄이기 위해 그래들 캐시 적용 + - name: Install ffmpeg + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.gitignore b/.gitignore index 162d9972..d010f309 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ out/ .env postgres_data/ src/main/resources/application-local.yml -.claude \ No newline at end of file +.claude + +### macOS ### +.DS_Store +**/.DS_Store diff --git a/docs/api-specs/adfit-api.md b/docs/api-specs/adfit-api.md new file mode 100644 index 00000000..024bd92f --- /dev/null +++ b/docs/api-specs/adfit-api.md @@ -0,0 +1,29 @@ +# 관리자 AdFit 광고와 ROI + +- `GET /api/v1/admin/adfit?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일. +- `PUT /api/v1/admin/adfit/daily`: ADMIN 전용. `{date, unit, revenue, cost, costBasis}`. +- `GET /api/v1/admin/adfit/session-cookie`: ADMIN 전용. `{configured, source, updatedAt}`. 쿠키 값은 응답에 담지 않는다. +- `PUT /api/v1/admin/adfit/session-cookie`: ADMIN 전용. `{cookie}`. AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체를 넣는다. +- `GET` 응답에는 기존 수동 입력 단위 리포트(`source`, `units`, `days`)와 별도로 `account`가 포함된다. +- `account.status`: `NOT_CONFIGURED`, `CONNECTED`, `RECONNECT_REQUIRED`, `UNAVAILABLE`. +- `account.days[]`: `{date, revenue, ctr, ecpm, fillRate, winFillRate}`. AdFit 콘솔에 값이 없거나 누락된 날짜는 `null`이며 0으로 대체하지 않는다. +- `account.fetchedAt`: `CONNECTED`일 때 이번 관리자 조회에서 AdFit 응답을 성공적으로 파싱한 시각이다. 원천 데이터의 최종 집계 시각이나 배치 동기화 시각이 아니다. +- `account.revenue`: 조회 기간 중 실제 내려온 일별 수익 합계. 수익 데이터가 전부 `null`이면 `null`. +- `account.cost`, `account.roi`: AdFit 계정 자동 보고서가 광고 비용을 제공하지 않으므로 항상 `null`. +- 자동 보고서는 세션 쿠키가 있을 때 AdFit 콘솔 계정 종합 일별 API를 조회한다. 쿠키는 매 조회 시점에 다시 읽는다. +- 쿠키 우선순위: 관리자 화면 입력값(`adfit_session_cookies` 테이블, `source=ADMIN_CONSOLE`) > 환경변수 `ADFIT_SESSION_COOKIE`·`picke.adfit.session-cookie`(`source=ENVIRONMENT`). 둘 다 없으면 `source=NONE`. +- 세션이 만료되면 `RECONNECT_REQUIRED`가 된다. 재배포 없이 `PUT /session-cookie`로 새 쿠키를 넣어 복구한다. +- 세션 쿠키가 없으면 `NOT_CONFIGURED`, 로그인 만료·리다이렉트·HTML 로그인 응답이면 `RECONNECT_REQUIRED`, API 장애·스키마 불일치면 `UNAVAILABLE`. +- `unit`: NATIVE_WIDE(홈·큐레이션·마이페이지 공유), BANNER(탐색), APP_TRANSITION(앱 시작). +- `costBasis`: AD_OPERATIONS(광고 운영비), ACQUISITION(유입 광고비), SERVICE_OPERATIONS(서비스 운영비). +- KRW 금액, 소수 둘째 자리까지, 음수 불가. 같은 날짜·단위는 수정. 미래 날짜 입력 불가. +- source=MANUAL_CONSOLE: `units`와 `days`는 수동 입력 데이터다. 자동 계정 수익은 `account`만 사용한다. +- 광고 단위는 로컬 Picke-iOS의 SDK 연결 기준이다. 개별 사용자에게 SDK가 선택한 이미지·광고주 소재를 재현하거나 실시간 노출을 보증하지 않는다. +- 같은 단위를 여러 화면에서 사용하더라도 수익은 한 번만 합산한다. 비용도 단위별 배분액으로 입력하며 전체 운영비를 각 단위에 중복 입력하지 않는다. +- 수익과 비용은 입력된 날짜들의 합계다. reportedDays/expectedDays로 부분 입력을 표시한다. 미입력은 null이며 0원이 아니다. +- ROI = (수익 - 비용) / 비용 × 100. 모든 날짜가 입력되고 같은 비용 기준이며 비용이 양수일 때만 계산한다. 그 외 null. +- 수익 0, 비용 양수인 정상 입력은 ROI -100%다. 미입력과 구분한다. +- AdFit 계정 자동 보고서는 콘솔 내부 API(`accountTotal/periodicIndicators`)를 사용한다. 공개 파트너 REST API가 아니므로 세션 만료 시 재연결이 필요하다. +- 공식 참고: https://adfit.kakao.com/ , https://adfit.github.io/ +- 카카오 REST API 키(`Authorization: KakaoAK ...`)로는 조회할 수 없다. 애드핏 매체주 수익용 공개 REST API가 없다. 카카오 디벨로퍼스 REST API 레퍼런스에 해당 엔드포인트가 없고, AdX Report API는 RTB 연동 DSP 전용, 카카오모먼트 리포트 API는 광고주 집행 측이다. +- DB: `docs/db/20260910_create_adfit_daily_reports.sql`, `docs/db/20260913_create_adfit_session_cookies.sql`. 현재 프로젝트는 Hibernate ddl-auto=update를 사용한다. diff --git a/docs/api-specs/admin-analytics-api.md b/docs/api-specs/admin-analytics-api.md new file mode 100644 index 00000000..862ef919 --- /dev/null +++ b/docs/api-specs/admin-analytics-api.md @@ -0,0 +1,65 @@ +# 관리자 지표 (Mixpanel · Sentry) + +- `GET /api/v1/admin/analytics/mixpanel?from=YYYY-MM-DD&to=YYYY-MM-DD&events=A,B`: ADMIN 전용. 최대 366일. +- `GET /api/v1/admin/analytics/sentry?from=YYYY-MM-DD&to=YYYY-MM-DD`: ADMIN 전용. 최대 366일. +- 두 응답의 `status`: `NOT_CONFIGURED`, `CONNECTED`, `UNAVAILABLE`. 조회 실패를 0으로 대체하지 않는다. +- `NOT_CONFIGURED`는 오류가 아니다. 토큰을 넣기 전 상태이므로 화면은 지면을 비우고 안내만 띄운다. + +## Mixpanel + +집계 API가 아니라 **원본 이벤트를 내려받아 서버가 직접 센다.** + +- `GET https://data.mixpanel.com/api/2.0/export?from_date&to_date`. 응답은 한 줄에 이벤트 하나인 NDJSON. +- 인증은 프로젝트 API 비밀을 Basic 사용자명 자리에 넣고 비밀번호를 비우는 레거시 방식이다. 이 방식에 `project_id`를 넣으면 400이 된다. 비밀이 이미 프로젝트를 특정한다. +- `events[].days[]`: `{date, count}`. 최신 날짜부터. 원본을 전부 받아 세므로 이벤트가 없던 날짜는 미집계가 아니라 **0**이다. +- `signUpDays[]`: 선택 기간 전체의 `sign_up` 일별 가입 수. 이벤트 선택값과 무관하게 집계하며 날짜 오름차순이다. +- `events`를 비우면 기간에 나타난 이벤트 전부를 발생 수 내림차순으로 준다. 이벤트 이름을 미리 설정해 둘 필요가 없다. +- 원본을 전부 받으므로 기간은 **31일까지**다(`MixpanelClient.MAX_DAYS`). 4일치가 약 2MB다. +- `properties.time`은 프로젝트 타임존 기준 epoch 초이고 `from_date`·`to_date` 경계도 같은 타임존을 따른다. 둘을 같은 타임존으로 묶어야 Mixpanel 화면 숫자와 맞는다. `picke.analytics.mixpanel.project-zone`(기본 `UTC`)로 맞춘다. 이 프로젝트는 UTC로 실측 확인했다. +- 경계 하루가 타임존 차이로 걸쳐 들어올 수 있어 요청 기간 밖 이벤트는 버린다. + +### 왜 집계 API를 안 쓰는가 + +- **현재 Picke의 Mixpanel 플랜은 Query API를 허용하지 않는다.** 2026-09-14 실측: `/api/query/segmentation`·`/api/query/insights` 모두 `HTTP 402 Your plan does not allow API calls`. 인증은 통과하므로 자격 문제가 아니다. +- 같은 플랜에서 Raw Export는 **200으로 열려 있다.** 그래서 이쪽으로 붙였다. Mixpanel MCP나 다른 클라이언트를 붙여도 Query API를 호출하는 한 같은 402를 받는다. +- 서비스 계정 방식(`username:secret`)은 이 프로젝트에서 401이다. 프로젝트 토큰(`project_token`)은 이벤트 수집용이라 조회 인증에 쓰이지 않는다(401). +- 실측(2026-09-14, 09-10~09-13): 16종 이벤트, `screen_view` 644 · `network_request` 561 · `ui_action` 172 · `onboarding_step` 65 · `battle_step` 44 · `sign_up` 3 등. + +### 환경변수 + +- `MIXPANEL_API_SECRET` 하나면 된다. 설정 키는 `picke.analytics.mixpanel.*`. +- EU·인도 데이터 거주 프로젝트는 호스트가 다르다. `picke.analytics.mixpanel.base-url`로 바꾼다. +- `ad_click` 이벤트에 `unit`·`placement`·`format` 속성이 붙어 온다. AdFit 클릭은 `unit` 없이 `format=popup`·`placement=app_start`로 들어온다. 광고 클릭을 매체별로 나누려면 이 속성을 쓴다. + +## Sentry 사용자 이벤트 + +- iOS 분석 모듈은 Mixpanel에 보내는 모든 이벤트를 Sentry에도 info 이벤트로 전송하고 `analytics_event` 태그를 붙인다. +- `projects[].analyticsEvents.events[]`는 이 태그를 기준으로 이벤트명·기간 발생 수·고유 사용자·최초/마지막 발생·일별 발생 수/고유 사용자를 제공한다. +- 이벤트 이름은 서버에 하드코딩하지 않는다. 선택 기간에 실제 수집된 태그 값을 전부 조회하므로 `sign_up`, `ui_action` 등 새 액션도 자동으로 노출된다. +- Sentry Explore의 한 요청당 상위 시계열 10개 제한 때문에 이벤트명을 먼저 조회한 뒤 10개씩 나누어 일별 추이를 조회한다. +- `analyticsEvents.status`가 `UNAVAILABLE`이어도 오류·세션·릴리즈 등 다른 Sentry 데이터는 가능한 범위에서 유지한다. + +## Sentry 오류 + +- `GET /api/0/projects/{org}/{project}/issues/?query=is:unresolved&sort=freq`. 조직 인증 토큰 `Authorization: Bearer`. +- 절대 기간을 쓰려면 `statsPeriod`를 빈 값으로 함께 보낸다. 생략하면 Sentry 기본 기간이 적용된다. +- iOS·Android를 각각 호출해 `projects[]`로 나눠 준다. +- 한 프로젝트가 막혀도 다른 프로젝트는 살린다. 대신 최상위 `status`는 `UNAVAILABLE`, 최상위 `totalEvents`는 `null`이다. 일부만 더한 값을 전체 합계처럼 보여주지 않는다. +- `projects[].totalEvents`는 stats API의 선택 기간 전체 수신 이벤트 합계다. `issues[]`는 미해결 상위 20건이다. +- `sort=freq`는 절대 기간에서 이벤트 수 내림차순을 보장하지 않는다(실측 확인). 응답 순서를 믿지 않고 서버가 다시 정렬한다. +- `issues[].events`는 Sentry가 문자열로 주기도 한다. 숫자·문자열 모두 읽는다. +- `recentEvents[]`는 `/events/?full=true`의 최근 오류 이벤트 최대 10건이다. 자주 쓰는 필드를 정규화하고 `details`에 사용자·브레드크럼·컨텍스트·예외·스택트레이스 등 전체 JSON을 보존한다. +- `datasets[]`는 Picke-iOS에서 활성화한 `errors`, `logs`, `spans`, `profile_functions`, `tracemetrics` 데이터셋의 일별 발생량이다. 각 데이터셋 실패는 프로젝트 핵심 오류/이슈 조회를 실패시키지 않는다. +- `metricCatalog`는 커스텀 메트릭의 이름·타입·단위·건수·마지막 수집 시각과 컨텍스트를 원본 형태로 준다. +- `sessionHealth`는 자동 세션 추적의 `healthy`, `errored`, `crashed` 등 상태별 합계와 일별 시리즈를 준다. +- `releases`는 프로젝트 최근 릴리즈 최대 20개의 버전·빌드·커밋·배포·상태 원본을 준다. +- iOS 설정에서 Session Replay는 샘플 비율이 0으로 꺼져 있으므로 Replay 녹화 데이터는 조회하지 않는다. +- 환경변수: `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`(기본 `picke`), `SENTRY_PROJECTS`(기본 `picke-ios,picke-android`). 설정 키는 `picke.analytics.sentry.*`. +- Explore·메트릭·세션 API는 `org:read`, 오류 이벤트는 `event:read`, 프로젝트·릴리즈 조회는 `project:read` 또는 해당 상위 권한이 필요하다. +- 앱은 이미 같은 org·project로 Sentry에 리포트한다. 토큰만 서버에 넣으면 같은 데이터를 읽는다. +- 실측(2026-09-14, 최근 30일): `picke-ios` 미해결 2건·1,765 이벤트, `picke-android` 9건·181 이벤트. + +## 공통 + +- 외부 호출 실패가 관리자 화면 전체를 500으로 만들지 않는다. 타임아웃은 연결 3초, 요청 10초다. +- 토큰은 응답에 담지 않는다. 서버 설정에만 둔다. diff --git a/docs/api-specs/attendance-api.md b/docs/api-specs/attendance-api.md index a7ceb84c..e28347cc 100644 --- a/docs/api-specs/attendance-api.md +++ b/docs/api-specs/attendance-api.md @@ -4,11 +4,11 @@ - 출석체크 API는 `snake_case` 필드명을 기준으로 합니다. - 출석 주기는 **월요일 기준 1주일(월~일)**로 초기화됩니다. -- 매일 1회 출석 체크 시 `+5P`를 지급하고, 7일 연속 출석 달성 시 추가로 `+7P`를 지급합니다. -- 7일 연속 출석 실패 시 다음 주 월요일에 연속 출석 카운트가 초기화됩니다. +- 매일 1회 출석 체크 시 `+5P`를 지급합니다. 단, 월~토 6일을 모두 출석한 뒤 맞이하는 일요일에는 `+5P` 대신 `+7P`를 **대체 지급**합니다(가산이 아님). +- 전날 출석 없이 출석 시 연속 출석 카운트가 1로 리셋됩니다. - 인증이 필요한 모든 API는 `Authorization: Bearer {access_token}` 헤더를 요구합니다. - 외부 응답에서는 내부 PK인 `user_id`를 노출하지 않고 `user_tag`를 사용합니다. -- 포인트 단위는 `P(포인트)`이며, 지급 내역은 별도 이력 테이블에 저장합니다. +- 포인트 단위는 `P(포인트)`이며, 지급 내역은 `credit_histories` 테이블에 저장됩니다. 출석체크 전용 이력 조회 API는 없고, 크레딧 적립/소비 통합 이력은 `GET /api/v1/me/credits/history`로 조회합니다. ### 1.1 출석 상태 정의 @@ -22,14 +22,15 @@ | 조건 | 지급 포인트 | |------|-------------| -| 매일 출석 체크 | `+5P` | -| 7일 연속 출석 달성 (일요일) | 추가 `+7P` | +| 평일(월~토) 출석 체크 | `+5P` | +| 일요일 출석 체크 (월~토 개근 시) | `+5P` 대신 `+7P` **대체 지급** | +| 일요일 출석 체크 (월~토 중 결석 있음) | `+5P` (평일과 동일) | -### 1.3 연속 출석 초기화 정책 +### 1.3 연속 출석(streak) 정책 -- 연속 출석 카운트는 **매주 월요일 00:00**에 초기화됩니다. -- 한 주(월~일) 내에 출석하지 않은 날이 있어도, 해당 주 일요일까지는 연속 출석 달성이 가능합니다. -- 단, 전날 출석 없이 오늘 출석 시 연속 카운트는 1로 리셋됩니다. +- 연속 출석 카운트(`consecutive_days`)는 통계·화면 표시용이며, 포인트 지급 여부는 **요일(일요일 여부)과 그 주 월~토 개근 여부**로만 결정됩니다. streak 값 자체가 포인트에 가산되지 않습니다. +- 전날 출석 기록이 없는 상태에서 오늘 출석하면 연속 카운트는 1로 리셋됩니다. +- 주간 통계는 매주 월요일 기준으로 초기화됩니다. --- @@ -40,8 +41,8 @@ 오늘의 출석을 체크하고 포인트를 지급합니다. - 하루에 1회만 출석 가능합니다. 중복 출석 시 `409`를 반환합니다. -- 출석 성공 시 `+5P`를 즉시 지급합니다. -- 7일 연속 출석 달성 시 `+5P`에 추가로 `+7P`를 지급합니다. +- 평일(월~토) 출석 성공 시 `+5P`를 즉시 지급합니다. +- 일요일 출석 시, 그 주 월~토를 모두 출석했다면 `+5P` 대신 `+7P`를 대체 지급합니다. 요청 헤더: @@ -65,7 +66,7 @@ } ``` -7일 연속 출석 달성 시 응답: +일요일에 월~토 개근 보너스가 대체 지급된 응답 (이 날은 `points_earned`가 5가 아니라 7로 대체되며, `streak_bonus_points`는 이 대체된 값과 동일합니다): ```json { @@ -73,7 +74,7 @@ "data": { "user_tag": "pique-5010f67e", "attended_at": "2025-07-13T09:00:00Z", - "points_earned": 5, + "points_earned": 7, "streak_bonus_earned": true, "streak_bonus_points": 7, "consecutive_days": 7, @@ -152,62 +153,12 @@ --- -### 2.4 `GET /api/v1/attendance/points/history` - -출석 포인트 지급 이력을 조회합니다. - -- 최신순으로 반환하며, 페이지네이션을 지원합니다. - -요청 헤더: - -- `Authorization: Bearer {access_token}` - -쿼리 파라미터: - -| 파라미터 | 타입 | 필수 | 설명 | -|----------|------|------|------| -| `page` | `integer` | N | 페이지 번호 (기본값: `1`) | -| `size` | `integer` | N | 페이지 크기 (기본값: `20`, 최대: `50`) | - -응답: - -```json -{ - "statusCode": 200, - "data": { - "total_count": 32, - "page": 1, - "size": 20, - "items": [ - { - "rewarded_at": "2025-07-10T09:00:00Z", - "points": 5, - "reason": "DAILY_ATTENDANCE", - "consecutive_days": 4 - }, - { - "rewarded_at": "2025-07-06T08:30:00Z", - "points": 7, - "reason": "STREAK_BONUS", - "consecutive_days": 7 - } - ] - }, - "error": null -} -``` - -가능한 `reason` 값: - -- `DAILY_ATTENDANCE` — 일일 출석 보상 (`+5P`) -- `STREAK_BONUS` — 7일 연속 출석 달성 보상 (`+7P`) - ---- +> 출석 포인트만 별도로 필터링해 조회하는 API는 없습니다. 크레딧 적립/소비 통합 이력은 `GET /api/v1/me/credits/history`(마이페이지 도메인)로 조회하며, 출석 보상은 `credit_type: TODAY_CREDIT`(평일 5P) 또는 `ATTENDANCE_STREAK`(일요일 대체 7P)로 구분됩니다. ## 3. 어드민 API - 어드민 전용 API는 `Authorization: Bearer {access_token}` 헤더와 함께 어드민 권한이 있는 계정만 호출 가능합니다. -- 어드민 권한이 없는 계정이 호출 시 `403`과 `FORBIDDEN`을 반환합니다. +- 어드민 권한이 없는 계정이 호출 시 `403`과 `AUTH_FORBIDDEN`을 반환합니다. ### 3.1 `GET /api/v1/admin/attendance/stats` @@ -352,7 +303,7 @@ | `400` | `COMMON_INVALID_PARAMETER` | 요청 파라미터가 잘못되었습니다. | | `401` | `AUTH_ACCESS_TOKEN_EXPIRED` | Access Token 만료 — Refresh 필요 | | `401` | `AUTH_REFRESH_TOKEN_EXPIRED` | Refresh Token 만료 — 재로그인 필요 | -| `403` | `FORBIDDEN` | 어드민 권한이 없는 사용자 | +| `403` | `AUTH_FORBIDDEN` | 어드민 권한이 없는 사용자 | | `403` | `USER_BANNED` | 영구 제재된 사용자 | | `403` | `USER_SUSPENDED` | 일정 기간 이용 정지된 사용자 | | `404` | `USER_NOT_FOUND` | 존재하지 않는 `user_tag` | diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index cfbcd2b3..6d918e9a 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -30,7 +30,40 @@ 기준 컨트롤러: `AdminBattleController` -### 2.1 배틀 생성 +### 2.1 배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함) +- `POST /api/v1/admin/battles/parse` +- 요청 본문(`AdminBattleParseRequest`): + - `rawText` (기획자가 구글독스에서 복사한 대본 원문 전체) +- 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **DB 저장은 하지 않는다.** 문서 포맷 규격/파싱 규칙은 `배틀_발행_시나리오_현행_vs_개선.md` 2.6, 실제 파싱 예시는 `배틀_대본_파싱_예시.md` 참고. +- 응답(`AdminBattleParseResponse`): + - `battlePayload` — 아래 2.2 배틀 생성 요청과 동일한 형태(`AdminBattleCreateRequest`). `status` 는 항상 `PENDING`. 문서에 없는 `thumbnailUrl`/`targetDate`/`publishAt`/`audioDuration` 은 `null` + - `summary` = 오프닝 전문을 LLM 이 40자 이내로 한 줄 요약한 것. 요약 생성 실패 시 오프닝 원문 그대로 들어가고 `SUMMARY_GENERATION_FAILED` warning 이 붙는다 + - `description` = 오프닝 전문 원문 그대로 + - `options[].title` = **사전 투표 영역의 짧은 선택지명이 있으면 그걸 우선 사용**(예: `"유죄다" vs "무죄다"` → `유죄다`/`무죄다`), 사전 투표가 없으면 메타데이터 표의 "선택지 명칭"으로 대체 + - `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다 + - `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름 + - `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다 +- **문장 단위 분리**: `scenarioPayload.nodes[].scripts[]` 는 원문의 한 발언(문단)을 그대로 담지 않고, 마침표/느낌표/물음표(`.` `!` `?`) 뒤 공백 기준으로 **문장마다 별도 script** 로 쪼갠다. 같은 발언에서 쪼개진 문장들은 `speakerName`/`speakerType`/`tone` 이 전부 동일하다. 오디오 파이프라인이 script 마다 무음(600ms)을 넣으므로 문장 단위 분리가 곧 자연스러운 끊어읽기가 된다. 종결부호 뒤에 닫는 따옴표(`"`/`'`)가 먼저 오면 그 지점은 안 끊긴다(알려진 제한사항). +- **선택지 설명(`interactiveOptions[].title`)**: `👉 [선택의 시간]` 아래 `A: {설명} (철학자)` / `B: {설명} (철학자)` 줄의 `{설명}` 부분이 그대로 들어간다. 인터랙티브(분기 O) 배틀에만 존재. + +**warning 코드** + +| code | 의미 | blocking | +|---|---|---| +| `MISSING_HEADER` | 첫 줄에서 `— 제목` 형식을 못 찾음 | no | +| `MISSING_METADATA` / `MISSING_CATEGORY` | 메타데이터 섹션/카테고리를 못 찾음 | no | +| `UNKNOWN_CATEGORY_TAG` | 그 카테고리 문자열의 CATEGORY 태그가 DB 에 없음 | no | +| `UNKNOWN_PHILOSOPHER_TAG` | 철학자 키워드가 철학자 유형 10인이 아님 | no | +| `MISSING_PHILOSOPHER_TAG` / `MISSING_VALUE_TAG` | 태그 문자열은 맞지만 DB row 가 없음 | no | +| `UNKNOWN_VALUE_TAG` | 성향 지표가 가치관 12축 문자열(원칙/결과/이성/감성/개인/관계/변화/전통/내면/구조/이상/현실)이 아님 | no | +| `MULTIPLE_TONE_TAGS` | 대사 한 줄에 톤 태그가 여러 개 → 첫 번째만 사용 | no | +| `SPEAKER_BINDING_FALLBACK` | 발화자↔A/B 매칭을 등장 순서로 임시 배정 | no | +| `LLM_CLASSIFY_FAILED` | 감정 자동분류 실패 → 톤이 전부 `NEUTRAL` | no | +| `SUMMARY_GENERATION_FAILED` | 오프닝 한 줄 요약 생성 실패 → `summary` 에 오프닝 원문이 그대로 들어감 | no | +| `MISSING_VOICE` | 발화자 보이스가 [철학자 보이스 매핑](./philosopher-voice-api.md) 에 없음 | **yes** | +| `INCOMPLETE_SPEAKER_BINDING` | A/B 발화자를 확정하지 못함 | **yes** | + +### 2.2 배틀 생성 - `POST /api/v1/admin/battles` - 요청 본문(`AdminBattleCreateRequest`) 주요 필드: - `title` @@ -47,21 +80,21 @@ - `imageUrl` - `tagIds` (철학자/가치관 태그 ID 목록) -### 2.2 배틀 목록 +### 2.3 배틀 목록 - `GET /api/v1/admin/battles` - 쿼리 파라미터: - `page` (기본값: `1`) - `size` (기본값: `10`) - `status` (선택) -### 2.3 배틀 상세 +### 2.4 배틀 상세 - `GET /api/v1/admin/battles/{battleId}` -### 2.4 배틀 수정 +### 2.5 배틀 수정 - `PATCH /api/v1/admin/battles/{battleId}` - 요청 본문(`AdminBattleUpdateRequest`) 필드 구조는 생성과 동일 -### 2.5 배틀 삭제 +### 2.6 배틀 삭제 - `DELETE /api/v1/admin/battles/{battleId}` --- diff --git a/docs/api-specs/dashboard-api.md b/docs/api-specs/dashboard-api.md new file mode 100644 index 00000000..8a20064d --- /dev/null +++ b/docs/api-specs/dashboard-api.md @@ -0,0 +1,293 @@ +# 어드민 대시보드 API 명세서 + +--- + +## 1. 설계 메모 + +- 모든 API는 `Authorization: Bearer {access_token}` 헤더가 필요하며, `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다. +- DAU(활동 유저)/로그인 유저 지표는 `user_daily_activities` 테이블 기반입니다. 유저-날짜(`user_id`, `activity_date`) 조합당 row 1개이며, 두 플래그를 관리합니다. + - `active`: 그날 인증된 API를 한 번이라도 호출했는지 (`JwtFilter`에서 매 요청마다 기록) + - `logged_in`: 그날 로그인(소셜/로컬)했는지 (`AuthService`에서 로그인 성공 시점에만 기록, 토큰 refresh는 제외) +- `totalUserCount`는 날짜 필터와 무관하게 현재 `UserStatus.ACTIVE` 상태인 전체 유저 수(누적)입니다. + +--- + +## 2. `GET /api/v1/admin/dashboard/summary` + +오늘(서버 로컬 날짜 기준) 요약 카드용 지표를 조회합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "newUserCount": 12, + "loginUserCount": 340, + "activeUserCount": 500, + "totalUserCount": 58000 + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `newUserCount` | 오늘 가입한 유저 수 (`users.created_at`) | +| `loginUserCount` | 오늘 로그인한 유저 수 (`user_daily_activities.logged_in=true`) | +| `activeUserCount` | 오늘 활동(DAU)한 유저 수 (`user_daily_activities.active=true`) | +| `totalUserCount` | 현재 `ACTIVE` 상태 전체 유저 수 (날짜 무관 누적) | + +--- + +## 3. `GET /api/v1/admin/dashboard/dau-mau` + +기간별 DAU/MAU 추이(꺾은선 그래프용)를 조회합니다. 활동이 없는 날짜도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, DAU) \| `month`(MAU) | + +`granularity=day`는 그날그날의 활동 유저 수(단순 카운트)를, `granularity=month`는 그날 기준 최근 30일 롤링 윈도우의 distinct 활동 유저 수(MAU)를 반환합니다. 예를 들어 `to=2026-08-15`일 때 8/15 항목의 `count`는 `2026-07-17~2026-08-15` 사이에 한 번이라도 활동한 유저 수입니다. + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "date": "2026-08-01", "count": 320 }, + { "date": "2026-08-02", "count": 410 } + ] + }, + "error": null +} +``` + +예외 응답 `400 - from이 to보다 늦음`: + +```json +{ + "statusCode": 400, + "data": null, + "error": { + "code": "COMMON_400", + "message": "요청 파라미터가 잘못되었습니다." + } +} +``` + +--- + +## 4. `GET /api/v1/admin/dashboard/new-users` + +기간별 신규 가입자 추이(꺾은선 그래프용)를 조회합니다. `dau-mau`와 동일한 파라미터 형태를 쓰되, 롤링 윈도우 없이 단순 카운트만 수행합니다. 가입자가 없는 날짜/주도 0으로 채워서 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | +| `granularity` | `string` | N | `day`(기본값, 일자별) \| `week`(주별, ISO 8601 월요일 시작) | + +`granularity=week`일 때 각 항목의 `date`는 그 주의 시작일(월요일)입니다. + +`totalCount`는 `from`~`to` 구간 전체의 정확한 합계입니다. `granularity=week`로 조회하면 주 경계가 `from`/`to`를 벗어나는 날짜까지 포함할 수 있어(예: `to`가 주 중간이면 그 주 전체가 한 항목으로 잡힘) `items`의 `count`를 그냥 더한 값과 `totalCount`가 다를 수 있습니다 — **임의 기간의 정확한 합계가 필요하면 `granularity` 값과 무관하게 `totalCount`를 사용하세요.** + +성공 응답 `200 OK` (`granularity=day`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 20, + "items": [ + { "date": "2026-08-01", "count": 12 }, + { "date": "2026-08-02", "count": 8 } + ] + }, + "error": null +} +``` + +성공 응답 `200 OK` (`granularity=week`): + +```json +{ + "statusCode": 200, + "data": { + "totalCount": 130, + "items": [ + { "date": "2026-07-27", "count": 65 }, + { "date": "2026-08-03", "count": 71 } + ] + }, + "error": null +} +``` + +`from`이 `to`보다 늦으면 `dau-mau`와 동일하게 `COMMON_400`으로 400을 반환합니다. + +--- + +## 5. `GET /api/v1/admin/dashboard/battle-stats` + +기간(`targetDate` 기준) 내 `PUBLISHED` 상태로 발행된 배틀들의 **배틀당 평균 참여율**을 조회합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 (배틀의 `targetDate` 기준) | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "battleCount": 5, + "avgPreVoteRate": 0.42, + "avgPostVoteRate": 0.31, + "avgPerspectiveWriteRate": 0.18, + "avgCommentWriteRate": 0.07 + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `battleCount` | 기간 내 발행된 배틀 수 | +| `avgPreVoteRate` | 배틀당 평균 사전투표 참여율 (0~1 비율) | +| `avgPostVoteRate` | 배틀당 평균 사후투표 참여율 | +| `avgPerspectiveWriteRate` | 배틀당 평균 관점(의견) 작성률 | +| `avgCommentWriteRate` | 배틀당 평균 댓글(다른 사람 관점에 대한 답글) 작성률 | + +각 배틀마다 (참여자 수 / 전체 `ACTIVE` 유저 수)로 참여율을 구한 뒤, 기간 내 배틀들의 평균을 냅니다. 분모는 **현재 시점 기준 전체 `ACTIVE` 유저 수**를 사용하는 근사치이며, 배틀이 발행되던 시점의 실제 유저 수가 아닙니다. 기간 내 발행된 배틀이 하나도 없으면 모든 비율 필드는 `0`입니다. + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 6. `GET /api/v1/admin/dashboard/attendance-stats` + +기간 내 출석 체크 현황과 개근 보너스 달성 건수를 조회합니다. 출석 체크는 로그인 시 자동으로 되는 게 아니라 유저가 명시적으로 출석 체크 API를 호출해야 기록됩니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "avgAttendanceRate": 0.12, + "totalCount": 850, + "streakAchievedCount": 42, + "items": [ + { "date": "2026-08-01", "count": 100 }, + { "date": "2026-08-02", "count": 120 } + ] + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `avgAttendanceRate` | 일자별 (출석 인원 / 전체 `ACTIVE` 유저 수)의 기간 평균 | +| `totalCount` | 기간 내 총 출석 횟수 | +| `streakAchievedCount` | 기간 내 개근 보너스(`CreditType.ATTENDANCE_STREAK`, 월~토 개근 후 일요일 출석 시 지급)를 받은 건수 | +| `items` | 일자별 출석 인원 수 (출석이 없는 날짜도 `0`으로 채움) | + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 7. `GET /api/v1/admin/dashboard/credit-stats` + +기간 내 크레딧 지급/차감 현황을 `CreditType`별로 집계합니다. 매주 월요일 배치(`MAJORITY_WIN`/`BEST_COMMENT`)가 정상 실행됐는지, 크레딧이 이상 지급되고 있진 않은지 확인하는 용도입니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `from` | `string` (`YYYY-MM-DD`) | Y | 조회 시작일 | +| `to` | `string` (`YYYY-MM-DD`) | Y | 조회 종료일 | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "totalGranted": 45000, + "totalDeducted": 3200, + "byType": [ + { "creditType": "TODAY_CREDIT", "count": 1200, "totalAmount": 6000 }, + { "creditType": "MAJORITY_WIN", "count": 340, "totalAmount": 1700 }, + { "creditType": "BATTLE_ENTRY", "count": 640, "totalAmount": -3200 }, + { "creditType": "FREE_CHARGE", "count": 890, "totalAmount": 17800 } + ] + }, + "error": null +} +``` + +| 필드 | 설명 | +|---|---| +| `totalGranted` | 기간 내 지급된 크레딧 총합 (`totalAmount`가 양수인 타입들의 합) | +| `totalDeducted` | 기간 내 차감된 크레딧 총합의 절대값 (`totalAmount`가 음수인 타입들의 합에 `-1`을 곱한 값) | +| `byType[].creditType` | `CreditType` enum 값 (`TODAY_CREDIT`, `ATTENDANCE_STREAK`, `DEFAULT_CREDIT`, `BATTLE_ENTRY`, `MAJORITY_WIN`, `BEST_COMMENT`, `FREE_CHARGE`, `TOPIC_SUGGEST`, `TOPIC_ADOPTED`) | +| `byType[].count` | 해당 타입의 지급/차감 건수 | +| `byType[].totalAmount` | 해당 타입의 금액 합계 (차감 타입은 음수로 내려감) | + +기간 내 발생 이력이 없는 타입은 `byType`에 포함되지 않습니다. + +`from`이 `to`보다 늦으면 `COMMON_400`으로 400을 반환합니다. + +--- + +## 8. 에러 코드 + +| Error Code | HTTP Status | 설명 | +|---|:---:|---| +| `AUTH_403` | `403` | 해당 API 접근 권한(관리자 권한)이 없습니다. | +| `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `from`이 `to`보다 늦은 경우) | diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index ae82f976..34be5a9c 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -158,7 +158,35 @@ --- -### 3.2 `GET /api/v1/notifications/{notificationId}` +### 3.2 `GET /api/v1/notifications/unread` + +알림함 벨 아이콘 배지 표시용으로, 미읽음 알림 존재 여부만 가볍게 확인합니다. 목록 조회와 달리 페이지네이션 없이 boolean 하나만 반환합니다. + +요청 헤더: + +- `Authorization: Bearer {access_token}` + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `category` | `string` | N | `ALL` \| `CONTENT` \| `NOTICE` \| `EVENT` (생략 시 전체 기준) | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "hasUnread": true + }, + "error": null +} +``` + +--- + +### 3.3 `GET /api/v1/notifications/{notificationId}` 알림 상세를 조회합니다. (목록과 동일한 필드 + `readAt`) @@ -202,7 +230,7 @@ --- -### 3.3 `PATCH /api/v1/notifications/{notificationId}/read` +### 3.4 `PATCH /api/v1/notifications/{notificationId}/read` 알림 1건을 읽음 처리합니다. @@ -222,7 +250,7 @@ --- -### 3.4 `PATCH /api/v1/notifications/read-all` +### 3.5 `PATCH /api/v1/notifications/read-all` 알림함의 모든 알림을 읽음 처리합니다. @@ -230,12 +258,14 @@ - `Authorization: Bearer {access_token}` -성공 응답 `200 OK`: +성공 응답 `200 OK`: 처리 직후 미읽음 알림 존재 여부(`hasUnread`)를 함께 내려줍니다. 모든 알림을 읽었다면 `hasUnread`는 `false`입니다. ```json { "statusCode": 200, - "data": null, + "data": { + "hasUnread": false + }, "error": null } ``` @@ -304,10 +334,231 @@ --- -## 6. 에러 코드 +## 6. 관리자 API + +모든 API는 `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`, 403 시 `COMMON_403` 등 공통 권한 에러). + +### 6.1 공지/이벤트 (`/api/v1/admin/notices`) + +즉시 발송되는 공지사항/이벤트 알림을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notices` | 공지사항/이벤트 작성 (즉시 전체 발송) | +| `GET` | `/api/v1/admin/notices/options` | 공지 작성 시 선택 가능한 `category` 목록 조회 | +| `GET` | `/api/v1/admin/notices/target-count` | 지금 등록하면 몇 대의 디바이스에 발송되는지 미리 조회 | +| `GET` | `/api/v1/admin/notices` | 공지 목록 조회 (`category`, `page`, `size` 쿼리) | +| `GET` | `/api/v1/admin/notices/{noticeId}` | 공지 상세 조회 | +| `PUT` | `/api/v1/admin/notices/{noticeId}` | 공지 수정 (제목/본문) | +| `DELETE` | `/api/v1/admin/notices/{noticeId}` | 공지 삭제 | +| `GET` | `/api/v1/admin/notices/{noticeId}/delivery-result` | 발송 결과 조회 (대상/성공/실패 건수) | +| `POST` | `/api/v1/admin/notices/test` | 특정 유저 대상 테스트 푸시 발송 (알림 설정 ON/OFF 무관) | + +**`POST /api/v1/admin/notices` 요청 바디** + +```json +{ + "category": "NOTICE", + "title": "서비스 점검 안내", + "body": "8/30 02:00~04:00 점검이 진행됩니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `category` | `string` | Y | `CONTENT` \| `NOTICE` \| `EVENT` | +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +응답은 `AdminNoticeDetailResponse` (`notificationId`, `category`, `detailCode`, `title`, `body`, `referenceId`, `createdAt`). + +**`GET /api/v1/admin/notices/options`** + +공지 작성 폼의 카테고리 선택지를 서버 enum 기준으로 내려줍니다. `ALL`은 목록 조회 필터용이라 작성 옵션에는 포함되지 않습니다. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "categories": ["CONTENT", "NOTICE", "EVENT"] + }, + "error": null +} +``` + +**`GET /api/v1/admin/notices/target-count`** + +`NOTICE`/`EVENT` 공지를 지금 등록하면 몇 대의 디바이스에 발송되는지 미리 조회합니다. `notifyAdminNotice`가 사용하는 것과 동일하게 '이벤트 및 소식 알림' 설정이 ON인 유저의 등록된 디바이스 수 기준입니다. `CONTENT` 카테고리 공지는 애초에 이 대상자 산정을 타지 않으므로 참고용으로만 사용하세요. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "targetCount": 1200 + }, + "error": null +} +``` + +**`PUT /api/v1/admin/notices/{noticeId}` 요청 바디** + +```json +{ + "title": "서비스 점검 안내 (수정)", + "body": "8/30 02:00~05:00로 점검 시간이 변경되었습니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +`category`/`detailCode`는 등록 시점에 확정되어 수정 대상이 아닙니다. 이미 발송된 알림함/푸시는 재발송되지 않고, 알림함에 남아있는 텍스트만 갱신됩니다. 응답은 등록 API와 동일한 `AdminNoticeDetailResponse`. + +**`DELETE /api/v1/admin/notices/{noticeId}`** + +성공 시 `200 OK`, `data: null`. 삭제된 공지는 알림함에서도 사라집니다. + +**`GET /api/v1/admin/notices/{noticeId}/delivery-result`** + +`CONTENT` 카테고리를 제외한 `NOTICE`/`EVENT` 공지처럼 실제로 푸시가 발송되는 알림에 한해, 발송 대상 디바이스 수와 성공/실패 건수를 조회합니다. 푸시 발송은 Android(FCM)는 동기, iOS(APNs)는 비동기로 처리되므로, 전체 발송이 끝나기 전에는 `pending: true`로 내려갑니다. + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "notificationId": 101, + "targetCount": 1200, + "successCount": 1180, + "failureCount": 20, + "pending": false + }, + "error": null +} +``` + +`failureCount`는 만료된 디바이스 토큰(`UNREGISTERED`, APNs의 `BadDeviceToken`/`Unregistered` 등) 정리 대상 건수를 포함한 전체 실패 건수이며, 실패 사유별 세부 분류는 제공하지 않습니다. + +예외 응답 `404 - 발송 결과 없음` (공지 자체가 없거나, `CONTENT` 카테고리처럼 관리자 발송 트리거를 거치지 않아 결과가 집계되지 않은 경우): + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_DELIVERY_RESULT", + "message": "발송 결과가 집계되지 않은 알림입니다." + } +} +``` + +**`POST /api/v1/admin/notices/test` 요청 바디** + +```json +{ + "userId": 123, + "title": "테스트 알림", + "body": "테스트 발송입니다." +} +``` + +### 6.2 예약 알림 (`/api/v1/admin/notification-schedules`) + +매일 지정된 시각에 전체 유저에게 자동 발송되는 예약 알림(`NotificationSchedule`)을 관리합니다. 매분 `NotificationScheduleDispatcher`가 `enabled=true`인 예약 중 `sendTime`(시:분)이 현재 시각과 일치하는 건을 찾아 발송하며, 같은 날 중복 발송되지 않도록 `lastSentDate`로 발송 이력을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notification-schedules` | 예약 알림 등록 | +| `GET` | `/api/v1/admin/notification-schedules` | 예약 알림 전체 목록 조회 | +| `GET` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 상세 조회 | +| `PUT` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 수정 (제목/부제목/발송시간/on-off 전체 교체) | +| `PATCH` | `/api/v1/admin/notification-schedules/{scheduleId}/toggle` | 예약 알림 On/Off만 전환 | +| `POST` | `/api/v1/admin/notification-schedules/{scheduleId}/test` | 저장된 제목/부제목으로 특정 유저에게 테스트 발송 | +| `DELETE` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 삭제 | + +**`POST` / `PUT` 요청 바디** + +```json +{ + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `subtitle` | `string` | Y | 알림 부제목(본문) | +| `sendTime` | `string` (`HH:mm:ss`) | Y | 매일 발송할 시각 (KST 기준, 분 단위로 매칭) | +| `enabled` | `boolean` | Y | 활성화 여부 | + +**`PATCH .../toggle` 요청 바디** + +```json +{ + "enabled": false +} +``` + +**`POST .../test` 요청 바디** + +```json +{ + "userId": 123 +} +``` + +저장되어 있는 예약 알림의 `title`/`subtitle`을 그대로 사용해, 지정한 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송합니다. 등록/수정 폼에서 저장 전 미리보기 용도로 쓰려면, 먼저 원하는 문구로 `POST`/`PUT`을 호출해 저장한 뒤 이 API로 확인하는 흐름을 권장합니다. + +**응답 (`AdminNotificationScheduleResponse`)** + +```json +{ + "statusCode": 200, + "data": { + "id": 3, + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true, + "createdAt": "2026-08-20T10:00:00" + }, + "error": null +} +``` + +목록 조회(`GET /api/v1/admin/notification-schedules`) 응답은 `{ "schedules": [AdminNotificationScheduleResponse, ...] }` 형태입니다. + +예외 응답 `404 - 예약 알림 없음`: + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_SCHEDULE", + "message": "존재하지 않는 알림 예약입니다." + } +} +``` + +--- + +## 7. 에러 코드 | Error Code | HTTP Status | 설명 | |---|:---:|---| | `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `fcmToken`/`platform` 누락) | | `USER_404` | `404` | 존재하지 않는 사용자입니다. | | `NOTIFICATION_404` | `404` | 존재하지 않는 알림입니다. (본인 소유가 아닌 알림 포함) | +| `NOTIFICATION_404_SCHEDULE` | `404` | 존재하지 않는 예약 알림입니다. | +| `NOTIFICATION_404_DELIVERY_RESULT` | `404` | 발송 결과가 집계되지 않은 알림입니다. | diff --git a/docs/api-specs/philosopher-voice-api.md b/docs/api-specs/philosopher-voice-api.md new file mode 100644 index 00000000..648948e5 --- /dev/null +++ b/docs/api-specs/philosopher-voice-api.md @@ -0,0 +1,44 @@ +# 철학자 보이스 매핑(Philosopher Voice) API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java` +`src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java` + +철학자 이름 → Fish Audio 보이스(`reference_id`) 매핑을 관리한다. [배틀 대본 붙여넣기 파서](./battle-api.md) 가 +시나리오 발화자(옵션 A/B 철학자)에 맞는 보이스를 여기서 조회해 `scenario.voiceSettings` 를 채운다. +전부 관리자 전용 API. 사용자 API 없음. + +## 1. 관리자 API + +### 1.1 목록 조회 +- `GET /api/v1/admin/philosopher-voices` +- 응답(`PhilosopherVoiceResponse[]`): `id`, `name`, `referenceId`, `voiceLabel`, `imageKey`, `note` + - **`imageKey`는 DB의 raw 저장 키가 아니라 `ResourceUrlProvider`로 변환된 호출 가능한 경로다** (다른 이미지 필드인 배틀 썸네일·옵션 이미지와 동일 규칙). 값이 있으면 그대로 ``에 써도 된다. `null`이면 이미지가 등록 안 된 것 — 프론트 기본 이미지로 대체 + +### 1.2 생성 +- `POST /api/v1/admin/philosopher-voices` +- 요청 본문(`PhilosopherVoiceRequest`): + - `name` (필수, 유니크) + - `referenceId` (필수, Fish Audio 보이스 모델 ID) + - `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`) + - `imageKey` (선택, 철학자 이미지 저장 키. 예: `"images/philosophers/rousseau.png"`) + - `note` (선택) +- 이름 중복 시 `PHILOSOPHER_VOICE_409_DUP` + +### 1.3 수정 +- `PATCH /api/v1/admin/philosopher-voices/{id}` +- 요청 본문은 생성과 동일. `name` 은 응답에서 그대로 유지되고 `referenceId`/`voiceLabel`/`note` 만 갱신된다 +- 대상이 없으면 `PHIL_VOICE_404` + +### 1.4 삭제 +- `DELETE /api/v1/admin/philosopher-voices/{id}` +- 대상이 없으면 `PHIL_VOICE_404` + +## 2. 상태/동작 메모 + +- 테이블은 `spring.jpa.hibernate.ddl-auto=update` 가 자동 생성한다. 소프트 삭제 없음 (참조하는 FK가 없어 hard delete) +- 초기 시드: `docs/db/20260910_seed_philosopher_voice.sql` (`ON CONFLICT (name) DO NOTHING`) — 환경별 1회 수동 실행 필요. 재실행해도 안전하고 어드민에서 수정한 값을 덮지 않는다 +- **NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config** (`fishaudio.voice-id.narrator`, `fishaudio.voice-id.user`). 이 API 는 A/B(철학자) 보이스 전용 +- 붙여넣기 파서가 발화자 이름으로 조회했는데 매핑이 없으면 `MISSING_VOICE` warning(blocking) 을 내고, 관리자가 미리보기에서 보이스를 고르거나 이 API 로 매핑을 추가한 뒤 다시 파싱해야 한다 +- 배틀별로 다른 보이스를 쓰고 싶으면 이 테이블을 바꾸지 않고 시나리오의 `voiceSettings` 를 직접 오버라이드하면 된다(이 테이블은 파서가 채우는 기본값 소스일 뿐) +- **`imageKey`**: `PhilosopherType` enum(철학자 유형 10인)만 실제 이미지가 있고, 그 밖의 철학자(예: 루소, 홉스, 쇼펜하우어)는 원래 이름을 해시해서 10개 이미지 중 하나를 무작위로 배정하는 폴백이 있었다(`PhilosopherType.resolveImageKey`) — 엉뚱한 철학자 이미지가 뜨는 문제가 있었음. 프론트는 배틀 생성 화면에서 이 API의 `imageKey` 로 정확한 이미지를 조회해 쓰고, 값이 없으면(아직 등록 안 된 철학자) 기본 이미지로 대체해야 한다. `imageKey` 는 `null` 이어도 되며, 랜덤 배정 로직으로 대신하지 않는다 diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index 5d211914..b1b9d47a 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -30,11 +30,13 @@ - `scripts[]` - `speakerName` - `speakerType` - - `text` + - `text` (오디오 효과 태그 `[sighing]` `[break]` 등은 인라인으로 그대로 둔다) + - `tone` (`Tone` enum, 생략 시 `NEUTRAL`: `NEUTRAL`/`ANGRY`/`SAD`/`EMBARRASSED`/`EMPHASIS`/`WHISPERING`/`SOFT_TONE`/`BREATHY`/`EXCITED`. 대사 한 줄의 감성적인 톤 — TTS 합성 시 `[tone]` 형태로 text 앞에 주입된다) - `interactiveOptions[]` - `label` + - `title` (선택 시 보여줄 선택지 설명, 생략 가능) - `nextNodeName` - - `voiceSettings` (`Map`) + - `voiceSettings` (`Map`, 값은 Fish Audio `reference_id`. 철학자 발화자(A/B)는 [철학자 보이스 매핑](./philosopher-voice-api.md)에서 조회해 채운다) ### 2.3 시나리오 본문 수정 - `PUT /api/v1/admin/scenarios/{scenarioId}` @@ -58,3 +60,5 @@ - 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행 - 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신 +- `text` 또는 `tone` 이 바뀐 스크립트만 오디오를 재생성한다 (그 외 조각은 S3 캐시 재사용) +- TTS 는 Fish Audio S2.1 Pro 사용 (`fishaudio.tts.model`, 기본값 `s2.1-pro`). 톤 태그는 `[...]` 대괄호로 `text` 앞에 붙어 전송된다 diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index 78594f02..16c49d2f 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -438,9 +438,47 @@ --- -## 4. 에러 코드 +## 4. 관리자 API -### 4.1 공통 에러 코드 +`ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`). + +### 4.1 `GET /api/v1/admin/users/search` + +닉네임 / 유저태그 / 이메일로 유저를 검색합니다. 어드민 페이지에서 특정 유저에게 테스트 알림을 보내는 등, 내부 `userId`를 알아야 하는 다른 관리자 API의 입력값을 찾기 위한 용도입니다. + +이메일은 소셜 로그인 유저의 `providerEmail`만 검색 대상이며, **로컬 로그인 유저는 이메일 필드 자체가 없어 닉네임/유저태그로만 검색**됩니다. + +쿼리 파라미터: + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `keyword` | `string` | Y | 닉네임/유저태그/이메일 중 하나라도 부분 일치하면 매칭 | +| `page` | `integer` | N | 페이지 번호 (기본값 `0`) | +| `size` | `integer` | N | 페이지 크기 (기본값 `20`) | + +성공 응답 `200 OK`: + +```json +{ + "statusCode": 200, + "data": { + "items": [ + { "userId": 123, "userTag": "picke_abcd", "nickname": "민초러버", "email": "user@gmail.com" }, + { "userId": 124, "userTag": "picke_efgh", "nickname": "로컬유저", "email": null } + ], + "hasNext": false + }, + "error": null +} +``` + +`email`은 소셜 계정이 없는 유저(로컬 로그인)의 경우 `null`로 내려갑니다. + +--- + +## 5. 에러 코드 + +### 5.1 공통 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| @@ -452,7 +490,7 @@ | `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 | | `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | -### 4.2 사용자 에러 코드 +### 5.2 사용자 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| diff --git a/docs/db/20260901_create_ad_tables.sql b/docs/db/20260901_create_ad_tables.sql new file mode 100644 index 00000000..22fd04bc --- /dev/null +++ b/docs/db/20260901_create_ad_tables.sql @@ -0,0 +1,72 @@ +-- 제휴 광고(쿠팡 파트너스 / 애드픽) 소재·클릭·노출 테이블 +-- 관련 설계: docs/superpowers/specs/2026-09-01-ad-picke-store-design.md +-- +-- 이 파일은 참고용이다. 운영은 spring.jpa.hibernate.ddl-auto=update 라 배포 시 자동 생성된다. +-- 스키마를 손으로 관리하는 환경이나 사후 검증이 필요할 때 쓴다. +-- +-- CHECK 제약은 엔티티의 @Check 로도 선언되어 있어 테이블 최초 생성 시 함께 만들어진다. +-- 다만 ddl-auto=update 는 이미 있는 제약을 갱신하지 않는다. +-- enum 에 값을 추가하면 아래 ALTER 를 별도로 실행해야 한다. +-- ALTER TABLE ad_creatives DROP CONSTRAINT ck_ad_creatives_slot; +-- ALTER TABLE ad_creatives ADD CONSTRAINT ck_ad_creatives_slot CHECK (slot IN (...)); +-- 빠뜨리면 새 지면의 INSERT 가 조용히 막힌다. AdCheckConstraintTest 가 제약과 enum 의 불일치를 먼저 잡는다. + +CREATE TABLE IF NOT EXISTS ad_creatives ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(16) NOT NULL UNIQUE, + network VARCHAR(20) NOT NULL, + slot VARCHAR(40) NOT NULL, + title VARCHAR(100) NOT NULL, + subtitle VARCHAR(200), + image_url VARCHAR(500) NOT NULL, + cta_text VARCHAR(30) NOT NULL, + landing_url VARCHAR(1000) NOT NULL, + status VARCHAR(20) NOT NULL, + source VARCHAR(20) NOT NULL DEFAULT 'MANUAL', + external_id VARCHAR(64), + target_os VARCHAR(20) NOT NULL DEFAULT 'ALL', + weight INTEGER NOT NULL DEFAULT 1, + starts_at TIMESTAMP, + ends_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_ad_creatives_network CHECK (network IN ('COUPANG', 'ADPICK')), + CONSTRAINT ck_ad_creatives_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')), + CONSTRAINT ck_ad_creatives_status CHECK (status IN ('DRAFT', 'ACTIVE', 'PAUSED')), + CONSTRAINT ck_ad_creatives_source CHECK (source IN ('MANUAL', 'ADPICK_API')), + CONSTRAINT ck_ad_creatives_target_os CHECK (target_os IN ('ALL', 'ANDROID', 'IOS')), + -- 애드픽 캠페인을 다시 찾는 키. 수동 등록 소재는 external_id 가 NULL 이라 중복되지 않는다. + CONSTRAINT uk_ad_creatives_external UNIQUE (source, external_id) +); + +-- 지면 조회는 (slot, status)로만 들어온다. +CREATE INDEX IF NOT EXISTS idx_ad_creatives_slot_status ON ad_creatives (slot, status); + +CREATE TABLE IF NOT EXISTS ad_click_logs ( + id BIGSERIAL PRIMARY KEY, + creative_id BIGINT NOT NULL, + slot VARCHAR(40) NOT NULL, + ip_hash VARCHAR(64), + user_agent VARCHAR(500), + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT ck_ad_click_logs_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')) +); + +CREATE INDEX IF NOT EXISTS idx_ad_click_logs_creative ON ad_click_logs (creative_id); +CREATE INDEX IF NOT EXISTS idx_ad_click_logs_created_at ON ad_click_logs (created_at); + +-- 노출은 raw 로그로 쌓지 않는다. 배너가 스크롤에 걸릴 때마다 행이 생기면 금방 수천만 건이 된다. +CREATE TABLE IF NOT EXISTS ad_impression_daily ( + id BIGSERIAL PRIMARY KEY, + creative_id BIGINT NOT NULL, + slot VARCHAR(40) NOT NULL, + stat_date DATE NOT NULL, + impressions BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_ad_impression_daily UNIQUE (creative_id, slot, stat_date), + CONSTRAINT ck_ad_impression_daily_slot CHECK (slot IN ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', 'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')) +); + +CREATE INDEX IF NOT EXISTS idx_ad_impression_daily_date ON ad_impression_daily (stat_date); diff --git a/docs/db/20260910_create_adfit_daily_reports.sql b/docs/db/20260910_create_adfit_daily_reports.sql new file mode 100644 index 00000000..46e168b9 --- /dev/null +++ b/docs/db/20260910_create_adfit_daily_reports.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS adfit_daily_reports ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP, + updated_at TIMESTAMP, + report_date DATE NOT NULL, + ad_unit VARCHAR(30) NOT NULL, + revenue NUMERIC(16, 2) NOT NULL CHECK (revenue >= 0), + cost NUMERIC(16, 2) NOT NULL CHECK (cost >= 0), + cost_basis VARCHAR(30) NOT NULL, + CONSTRAINT uk_adfit_daily_date_unit UNIQUE (report_date, ad_unit) +); diff --git a/docs/db/20260910_seed_philosopher_voice.sql b/docs/db/20260910_seed_philosopher_voice.sql new file mode 100644 index 00000000..ebf6f695 --- /dev/null +++ b/docs/db/20260910_seed_philosopher_voice.sql @@ -0,0 +1,72 @@ +-- 철학자 → Fish Audio 보이스(reference_id) 초기 매핑 시드 +-- 관련 설계: 배틀_발행_시나리오_현행_vs_개선.md 2.5 +-- +-- 테이블 자체는 spring.jpa.hibernate.ddl-auto=update 가 PhilosopherVoice 엔티티를 보고 생성한다. +-- 이 스크립트는 초기 데이터만 넣는다. 환경별(dev / prod / local)로 1회 실행. +-- ON CONFLICT DO NOTHING 이라 재실행해도 안전하고, 어드민에서 수정한 값을 덮지 않는다. +-- 이후 매핑 변경/추가는 어드민 CRUD(/api/v1/admin/philosopher-voices)로 한다. + +INSERT INTO philosopher_voice (name, reference_id, voice_label, created_at, updated_at) VALUES + ('소크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('스마일즈', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('플라톤', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('톨스토이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('맹자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('공자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('칙센트미하이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('히포크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + + ('아리스토텔레스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('벤담', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('한비자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('피터싱어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('볼테르', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('푸코', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('토크빌', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('베카리아', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('스피노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('애덤 그랜트', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('버크', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('쇼펜하우어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('보르헤스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('월드런', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + + ('홉스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('비트겐슈타인', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('도스토옙스키', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('니체', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('밀', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('뒤르켐', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('베이컨', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('존 로크', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('루소', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('에피쿠로스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사르트르', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('간디', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사마천', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('프로이트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('카뮈', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('데카르트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + + ('칸트', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('애들러', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마키아벨리', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('엘리아데', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('프롬', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('융', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('롤스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('왈처', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('암베드카르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('라이프니츠', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('카너먼', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('도킨스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('흄', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('보드리야르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('몽테스키외', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마이어스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마르크스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('바흐', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + + ('아렌트', '90597c459f7a4438801589a5268c1628', '차분하고 지적인(여성)', now(), now()) +ON CONFLICT (name) DO NOTHING; diff --git a/docs/db/20260912_update_philosopher_voice_image_key.sql b/docs/db/20260912_update_philosopher_voice_image_key.sql new file mode 100644 index 00000000..2e3a8c21 --- /dev/null +++ b/docs/db/20260912_update_philosopher_voice_image_key.sql @@ -0,0 +1,35 @@ +-- philosopher_voice.image_key 채우기 +-- 관련 이슈: #475 (PR #476) +-- +-- Railway Bucket images/philosophers/ 에 실제 업로드된 파일과 이름이 일치하는 +-- 22명만 채운다. 나머지 36명(voice는 있지만 이미지 없음)은 image_key = NULL 유지 — +-- 프론트가 null 이면 기본 플레이스홀더 이미지로 대체해야 한다(philosopher-voice-api.md 참고). +-- +-- images/philosophers/ 에는 이 22명 외에 6개 파일이 더 있다 +-- (aquinas, choe_hangi, jeong_yakyong, xunzi, yi_hwang, yi_i) — +-- 이 6명은 philosopher_voice 에 row 자체가 없다(Fish 보이스 미정이라 별도 처리 필요). +-- +-- 환경별(dev/prod) 1회 실행. 재실행해도 안전(멱등 UPDATE). + +UPDATE philosopher_voice SET image_key = 'images/philosophers/aristotle.png', updated_at = now() WHERE name = '아리스토텔레스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/buddha.png', updated_at = now() WHERE name = '석가모니'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/camus.png', updated_at = now() WHERE name = '카뮈'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/confucius.png', updated_at = now() WHERE name = '공자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/descartes.png', updated_at = now() WHERE name = '데카르트'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/epicurus.png', updated_at = now() WHERE name = '에피쿠로스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/fromm.png', updated_at = now() WHERE name = '프롬'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/hobbes.png', updated_at = now() WHERE name = '홉스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/hume.png', updated_at = now() WHERE name = '흄'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/jung.png', updated_at = now() WHERE name = '융'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/kant.png', updated_at = now() WHERE name = '칸트'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/laozi.png', updated_at = now() WHERE name = '노자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/leibniz.png', updated_at = now() WHERE name = '라이프니츠'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/marx.png', updated_at = now() WHERE name = '마르크스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/mencius.png', updated_at = now() WHERE name = '맹자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/mill.png', updated_at = now() WHERE name = '밀'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/nietzsche.png', updated_at = now() WHERE name = '니체'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/plato.png', updated_at = now() WHERE name = '플라톤'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/rawls.png', updated_at = now() WHERE name = '롤스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/sartre.png', updated_at = now() WHERE name = '사르트르'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/schopenhauer.png', updated_at = now() WHERE name = '쇼펜하우어'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/socrates.png', updated_at = now() WHERE name = '소크라테스'; diff --git a/docs/db/20260913_create_adfit_session_cookies.sql b/docs/db/20260913_create_adfit_session_cookies.sql new file mode 100644 index 00000000..5ff514f8 --- /dev/null +++ b/docs/db/20260913_create_adfit_session_cookies.sql @@ -0,0 +1,8 @@ +-- AdFit 콘솔 세션 쿠키 보관용. 관리자 화면에서 갱신해 재배포 없이 자동 수익 조회를 복구한다. +-- 한 행만 두고 쓴다. 조회는 id 역순 첫 행을 읽으므로 행이 늘어도 마지막 값이 이긴다. +CREATE TABLE IF NOT EXISTS adfit_session_cookies ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at TIMESTAMP, + updated_at TIMESTAMP, + cookie_value VARCHAR(4096) NOT NULL +); diff --git a/docs/erd/ad.puml b/docs/erd/ad.puml new file mode 100644 index 00000000..12b81323 --- /dev/null +++ b/docs/erd/ad.puml @@ -0,0 +1,82 @@ +@startuml ad +hide circle +hide methods +skinparam linetype ortho + +entity "ad_creatives" as ad_creatives { + * id : BIGINT <> + -- + code : VARCHAR(16) <> + network : VARCHAR(20) + slot : VARCHAR(40) + title : VARCHAR(100) + subtitle : VARCHAR(200) <> + image_url : VARCHAR(500) + cta_text : VARCHAR(30) + landing_url : VARCHAR(1000) + status : VARCHAR(20) + source : VARCHAR(20) + external_id : VARCHAR(64) <> + target_os : VARCHAR(20) + weight : INTEGER + starts_at : TIMESTAMP <> + ends_at : TIMESTAMP <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ad_click_logs" as ad_click_logs { + * id : BIGINT <> + -- + creative_id : BIGINT + slot : VARCHAR(40) + ip_hash : VARCHAR(64) <> + user_agent : VARCHAR(500) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "ad_impression_daily" as ad_impression_daily { + * id : BIGINT <> + -- + creative_id : BIGINT + slot : VARCHAR(40) + stat_date : DATE + impressions : BIGINT + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +ad_creatives ||--o{ ad_click_logs : clicked +ad_creatives ||--o{ ad_impression_daily : impressed + +note bottom of ad_creatives + 제휴 광고 소재. 각 매체 콘솔에서 발급한 + 완성형 제휴 링크를 어드민이 등록한다. + network : COUPANG | ADPICK + status : DRAFT | ACTIVE | PAUSED + source : MANUAL | ADPICK_API + target_os : ALL | ANDROID | IOS + + ADPICK_API 소재는 캠페인 API 가 주기적으로 + 덮어쓴다. external_id 는 애드픽 apOffer 다. +end note + +note bottom of ad_click_logs + 클릭 원장. 제휴사 리포트와 대조하는 용도다. + /c/{code}는 외부 브라우저에서 열려 + Authorization 헤더가 없으므로 사용자를 특정하지 않는다. +end note + +note bottom of ad_impression_daily + 일별 노출 집계. (creative_id, slot, stat_date) 유니크. + 노출을 raw 로그로 쌓으면 배너가 스크롤에 걸릴 때마다 + 행이 생겨 금방 수천만 건이 된다. +end note + +note top of ad_creatives + creative_id는 논리적 참조다. + 소재를 지워도 집계 이력은 남기려고 FK를 걸지 않았다. +end note + +@enduml diff --git a/docs/erd/notice-notification.puml b/docs/erd/notice-notification.puml index 5402b62e..be440e83 100644 --- a/docs/erd/notice-notification.puml +++ b/docs/erd/notice-notification.puml @@ -7,35 +7,70 @@ entity "USERS\n사용자" as users { * id : Long <> } -entity "NOTICES\n전체 공지" as notices { +entity "NOTIFICATIONS\n인앱 알림 / 공지 (user_id NULL이면 전체 broadcast)" as notifications { * id : Long <> -- - title : string - body : text - notice_type : string - is_pinned : boolean - starts_at : datetime - ends_at : datetime + user_id : Long <> + category : ENUM('CONTENT', 'NOTICE', 'EVENT') + detail_code : ENUM('NEW_BATTLE', 'COMMENT_LIKE', 'NEW_COMMENT', 'CREDIT_EARNED', 'POLICY_CHANGE', 'PROMOTION', 'VOTE_RESULT', 'DAILY_MESSAGE') + title : VARCHAR(150) + body : TEXT + reference_id : Long (nullable) + perspective_id : Long (nullable) + is_read : boolean + read_at : datetime (nullable) + deleted_at : datetime (nullable) created_at : datetime + updated_at : datetime } -entity "NOTIFICATIONS\n알림 발송 이력" as notifications { +entity "NOTIFICATION_READS\nbroadcast 알림 유저별 읽음 처리" as notification_reads { * id : Long <> -- - user_id : Long <> - notification_type : string - title : string - body : text - payload_json : text - status : string - scheduled_at : datetime - sent_at : datetime - failed_at : datetime - provider_message_id : string - failure_reason : string + notification_id : Long <> + user_id : Long created_at : datetime + updated_at : datetime + UNIQUE(notification_id, user_id) +} + +entity "NOTIFICATION_SCHEDULES\n관리자 예약 발송(매일 정해진 시각)" as notification_schedules { + * id : Long <> + -- + title : VARCHAR(150) + subtitle : VARCHAR(150) + send_time : TIME + enabled : boolean + last_sent_date : DATE (nullable) + created_at : datetime + updated_at : datetime +} + +entity "NOTIFICATION_DELIVERY_RESULTS\n공지/예약 발송 결과(대상/성공/실패 건수)" as notification_delivery_results { + * id : Long <> + -- + notification_id : Long <> + target_count : int + success_count : int + failure_count : int + created_at : datetime + updated_at : datetime } users ||--o{ notifications +notifications ||--o{ notification_reads +notifications ||--o| notification_delivery_results + +note right of notifications + user_id가 NULL이면 전체 broadcast 알림(공지/이벤트/예약메시지). + broadcast 알림의 유저별 읽음 상태는 notification_reads에 별도 기록하고, + 개인 알림(user_id 존재)의 읽음 상태는 is_read/read_at을 직접 사용한다. + deleted_at은 관리자 공지 soft delete용. +end note + +note right of notification_delivery_results + notifyAdminNotice()로 발송되는 공지(NOTICE/EVENT)와 예약 알림만 생성된다. + CONTENT 카테고리 공지는 이 발송 트리거를 타지 않아 결과가 없다. +end note @enduml diff --git a/docs/erd/philosopher-voice.puml b/docs/erd/philosopher-voice.puml new file mode 100644 index 00000000..fc66a171 --- /dev/null +++ b/docs/erd/philosopher-voice.puml @@ -0,0 +1,50 @@ +@startuml philosopher-voice +hide circle +hide methods +skinparam linetype ortho + +entity "philosopher_voice" as philosopher_voice { + * id : BIGINT <> + -- + name : VARCHAR(50) <> + reference_id : VARCHAR(64) + voice_label : VARCHAR(100) <> + image_key : VARCHAR(500) <> + note : VARCHAR(255) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "scenarios" as scenarios { + * id : BIGINT <> +} + +entity "scenario_voice_settings" as scenario_voice_settings { + * scenario_id : BIGINT <> + * speaker_type : VARCHAR(20) + -- + voice_code : VARCHAR(200) +} + +scenarios ||--o{ scenario_voice_settings : has +philosopher_voice ..> scenario_voice_settings : 파서가 voice_code 채움 + +note bottom of philosopher_voice + 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + 붙여넣기 파서가 시나리오 발화자(옵션 A/B 철학자)에 맞는 + reference_id 를 조회해 scenario_voice_settings.voice_code 에 채운다. + NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config. + + FK 없음: scenario_voice_settings 가 name 이 아닌 완성된 + voice_code(reference_id) 를 저장하므로 논리적 참조만 존재. + 매핑 변경/추가는 어드민(/api/v1/admin/philosopher-voices)에서 한다. + 초기 데이터는 docs/db/20260910_seed_philosopher_voice.sql. + + image_key : 철학자 이미지 저장 키(S3/Railway Bucket). + PhilosopherType enum(철학자 유형 10인) 밖 철학자는 원래 + 이름 해시로 10개 이미지 중 하나를 무작위 배정했는데, + 그 대신 프론트가 이 값으로 정확한 이미지를 쓰게 하려고 추가. + 없으면 프론트가 기본 이미지로 대체해야 한다. +end note + +@enduml diff --git a/docs/erd/scenario.puml b/docs/erd/scenario.puml index 0e5d2e38..83094c9f 100644 --- a/docs/erd/scenario.puml +++ b/docs/erd/scenario.puml @@ -41,6 +41,7 @@ entity "scenario_scripts" as scenario_scripts { speaker_type : VARCHAR(20) speaker_name : VARCHAR(100) text : TEXT + tone : VARCHAR(20) audio_url : VARCHAR(500) script_order : INT created_at : TIMESTAMP @@ -52,6 +53,7 @@ entity "scenario_options" as scenario_options { -- node_id : BIGINT <> label : VARCHAR(200) + title : VARCHAR(255) <> next_node_id : BIGINT option_order : INT created_at : TIMESTAMP @@ -84,4 +86,19 @@ note right of scenarios voice_settings는 화자별 보이스 코드 저장 end note +note bottom of scenario_options + title : 선택의 시간 화면에 보여줄 선택지 설명 + (예: "첫 만남에는 그에 걸맞은 격조와 + 분위기가 필수다, 유죄!"). nullable. +end note + +note bottom of scenario_scripts + tone : 대사 한 줄의 감성적인 톤 (Tone enum) + NEUTRAL | ANGRY | SAD | EMBARRASSED | EMPHASIS + | WHISPERING | SOFT_TONE | BREATHY | EXCITED + NEUTRAL 이면 TTS 전송 시 톤 태그를 붙이지 않음. + 오디오 효과 태그([sighing] 등)는 text 인라인에 그대로 둔다. + tone 이 바뀌면 audio_url 을 비워 재합성한다. +end note + @enduml diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml index db748bcc..f722a71b 100644 --- a/docs/erd/user-ops.puml +++ b/docs/erd/user-ops.puml @@ -33,14 +33,27 @@ entity "USER_AGREEMENTS\n사용자 동의 이력" as user_agreements { agreed_at : timestamp } -entity "USER_DEVICES\n사용자 디바이스" as user_devices { +entity "USER_DEVICES\n사용자 디바이스(FCM/APNs 토큰)" as user_devices { * id : BIGINT <> -- user_id : BIGINT <> - device_token : string - platform : string - last_seen_at : timestamp + fcm_token : VARCHAR(255) <> + platform : ENUM('ANDROID', 'IOS') + last_used_at : timestamp created_at : timestamp + updated_at : timestamp +} + +entity "USER_DAILY_ACTIVITIES\n어드민 대시보드 DAU/MAU 집계용 유저-날짜별 활동" as user_daily_activities { + * id : BIGINT <> + -- + user_id : BIGINT <> + activity_date : DATE + logged_in : boolean + active : boolean + created_at : timestamp + updated_at : timestamp + UNIQUE(user_id, activity_date) } entity "USER_BLOCKS\n사용자 차단" as user_blocks { @@ -54,18 +67,20 @@ entity "USER_BLOCKS\n사용자 차단" as user_blocks { users -[hidden]down- user_settings user_settings -[hidden]down- user_agreements user_agreements -[hidden]down- user_devices -user_devices -[hidden]down- user_blocks +user_devices -[hidden]down- user_daily_activities +user_daily_activities -[hidden]down- user_blocks users ||--|| user_settings users ||--o{ user_agreements users ||--o{ user_devices +users ||--o{ user_daily_activities users ||--o{ user_blocks : blocker users ||--o{ user_blocks : blocked note bottom of user_blocks 공통 컬럼 정책 - BaseEntity: created_at, updated_at - - agreed_at, last_seen_at 은 도메인별 개별 컬럼 + - agreed_at, last_used_at 은 도메인별 개별 컬럼 end note @enduml diff --git a/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md new file mode 100644 index 00000000..d49c42d5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-01-ad-picke-store-design.md @@ -0,0 +1,269 @@ +# ad.picke.store 제휴 광고 설계 + +- 작성일: 2026-09-01 +- 상태: 승인됨 (구현 진행) +- 범위: 쿠팡 파트너스 + 애드픽 제휴 광고를 앱 지면에 노출하고, 클릭을 추적·집계한다. + +## 1. 배경과 목표 + +앱 화면 중간중간에 제휴 광고 배너를 노출해 수익을 만든다. 배너는 **앱이 네이티브로 렌더**하고, +탭하면 **외부 브라우저로 제휴 링크에 다이렉트**된다. + +매체는 두 곳이다. + +| 매체 | 식별자 | 성격 | +| --- | --- | --- | +| 쿠팡 파트너스 | `AF6830373` | 커머스 CPS. 상품 구매 전환 | +| 애드픽 | 가입 예정 | 성과형 CPA/CPI. 앱 설치·이벤트 참여 | + +네이버(쇼핑커넥트)는 **범위에서 제외**한다. 가입 단위가 블로그·인스타 같은 크리에이터 채널이라 +앱을 매체로 등록하는 경로가 없고, 인증 채널 밖에 링크를 게시하면 약관 위반 소지가 있다. + +### 목표가 아닌 것 + +- 쿠팡 파트너스 오픈API 연동. 파트너스 실적 요건 충족 후 승인제라 지금은 쓸 수 없다. + 소재 자동 수급은 `AdCreative` 생성 경로만 추가하면 되므로 나중에 얹는다. +- 사용자별 클릭 귀속. 3.4 참조. +- AdMob 리워드 광고 통합. 이미 `reward` 도메인에 별도로 존재한다. + +## 2. 접근 방식 + +**어드민 수동 등록 + 서버 리다이렉트 트래킹.** + +각 매체 콘솔에서 뽑은 완성형 제휴 링크를 어드민에 소재로 등록한다. 앱은 지면 코드로 소재를 조회해 +네이티브로 그리고, 탭하면 우리 서버의 리다이렉트 엔드포인트를 거쳐 제휴 링크로 나간다. + +두 매체를 하나의 파이프라인으로 처리할 수 있고 외부 API 의존이 없다. 대신 소재를 사람이 채워야 하고, +상품 가격·품절이 실시간 반영되지 않는다. 소재 수가 수십 개 규모라 감당 가능한 비용으로 본다. + +### 기각한 대안 + +- **WebView 임베드**: 소재 교체가 앱 배포와 무관해지지만, 스크롤 중첩·렌더 지연·다크모드 불일치가 생긴다. + "앱 UI에 네이티브로 보여야 한다"는 요구와 어긋난다. +- **애드픽 마이도메인**: 애드픽이 자체 도메인 트래킹 링크를 지원하나, 애드픽 링크만 커버한다. + 쿠팡까지 한곳에서 집계하고 클릭 로그를 우리 DB에 두려면 자체 리다이렉트가 맞다. + +## 3. 설계 + +### 3.1 패키지 구조 + +``` +domain/ad/ + controller/ AdController 앱 조회 API + AdClickController /c/{code} 302 리다이렉트 + AdLandingController ad.picke.store 루트 공개 지면 + service/ AdQueryService AdClickService + link/ AffiliateLinkBuilder CoupangLinkBuilder AdpickLinkBuilder + entity/ AdCreative AdClickLog AdImpressionDaily + enums/ AdNetwork AdSlotCode AdStatus + repository/ dto/ +domain/admin/ AdminAdController + AdminAdService (기존 어드민 관례를 따른다) +``` + +`reward` 도메인의 `AdRewardHistory`(AdMob)와 이름이 겹쳐 보이지만, 그쪽은 리워드 광고 시청 보상이고 +이쪽은 제휴 광고다. `AdNetwork` enum으로 구분된다. + +### 3.2 지면(slot) + +`AdSlotCode` **enum으로 둔다. 테이블이 아니다.** + +어드민에서 지면을 새로 만들어도 앱이 그 지면을 그릴 줄 모르면 아무 일도 일어나지 않는다. +지면 추가는 어차피 앱 배포와 묶이므로, 테이블로 빼면 실제로 쓸 수 없는 유연성만 생긴다. + +지면 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. +앱팀 확정 전이므로, 실제로 붙이는 지면에만 소재를 등록하면 된다. 소재가 없는 지면은 빈 배열을 주고 +앱은 지면 자체를 숨기므로 미사용 지면이 남아 있어도 부작용이 없다. + +| 지면 | 화면 | CPI 허용 | +| --- | --- | --- | +| `HOME_FEED` | 홈 피드 인라인 | 아니오 | +| `BATTLE_RESULT_BOTTOM` | 배틀 결과 하단 | 예 | +| `CHAT_ROOM_INLINE` | 관점 목록 인라인 | 아니오 | +| `ATTENDANCE_COMPLETE` | 출석 완료 후 | 예 | +| `PROFILE_BOTTOM` | 프로필 하단 | 예 | + +`cpiFriendly`는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. 5장 트레이드오프 참조. + +### 3.3 데이터 모델 + +**`ad_creatives`** — 소재. `BaseEntity` 상속(id, created_at, updated_at). + +| 컬럼 | 타입 | 설명 | +| --- | --- | --- | +| `code` | varchar(16) unique | 공개 클릭 URL용 짧은 코드. PK 노출 방지 | +| `network` | varchar | `COUPANG` \| `ADPICK` | +| `slot` | varchar | `AdSlotCode` | +| `title` | varchar(100) | 배너 주 문구 | +| `subtitle` | varchar(200) nullable | 보조 문구 | +| `image_url` | varchar(500) | 소재 이미지 | +| `cta_text` | varchar(30) | "구매하러 가기" / "설치하고 받기" 등 | +| `landing_url` | varchar(1000) | 콘솔에서 뽑은 원본 제휴 링크 | +| `status` | varchar | `DRAFT` \| `ACTIVE` \| `PAUSED` | +| `weight` | int | 가중 로테이션. 기본 1 | +| `starts_at` / `ends_at` | timestamp nullable | 게재 기간. null이면 무제한 | + +`cta_text`를 매체별 하드코딩이 아니라 소재 단위로 두는 이유는, 쿠팡은 상품 구매이고 애드픽은 +앱 설치·이벤트 참여라 문구 성격이 다르기 때문이다. + +**`ad_click_logs`** — 클릭 원장. creative_id, slot, ip_hash, user_agent, clicked_at. +제휴사 리포트와 대조하는 용도다. + +**`ad_impression_daily`** — 노출 집계. (creative_id, slot, stat_date) 유니크 + impressions 카운터. + +노출은 raw 로그로 쌓지 않는다. 배너가 스크롤에 걸릴 때마다 행이 생기면 금방 수천만 건이 된다. +일별 upsert 카운터로 CTR을 뽑는 데 충분하다. + +### 3.4 클릭 로그는 익명이다 + +`/c/{code}`는 **외부 브라우저에서 열린다.** Authorization 헤더가 없다. + +사용자를 붙이려면 클릭 URL에 사용자 식별자를 실어야 하는데, 공개 URL에 그걸 넣으면 열거 공격과 +프라이버시 문제가 생긴다. 서명된 단기 토큰을 발급하는 방법도 있지만 v1에 그만한 값어치가 없다. + +지면별 CTR과 정산 대조에는 userId가 필요 없으므로 **v1은 익명(ip_hash + user_agent)으로 간다.** +본인 클릭 어뷰징 탐지가 필요해지면 그때 추가한다. + +### 3.5 API + +모든 앱/어드민 API는 `/api/v1/` 아래에 둔다. + +**앱** + +- `GET /api/v1/ads?slot={AdSlotCode}` → `ApiResponse>` + `{ code, network, title, subtitle, imageUrl, ctaText, clickUrl, label }` + `clickUrl` = `https://ad.picke.store/c/{code}` + `label`은 `"광고"` 고정. 표시광고법 대응이므로 앱이 반드시 렌더해야 한다. +- `POST /api/v1/ads/impressions` — `{ codes: [...] }` 묶음 전송 + +조회 시점에 노출을 집계하면 엔드포인트 하나를 아끼지만 **조회 ≠ 실제 노출**이라 CTR이 왜곡된다. +지면 성과로 배치를 정할 것이므로 분리한다. + +**클릭 리다이렉트** + +- `GET /c/{code}` → 302 Location: 제휴 링크 + +`/api/v1` 밑에 두지 않는다. 공개 숏링크라 짧아야 하고, JSON API가 아니라 브라우저 진입점이다. + +`landing_url`에 매체별 추적 파라미터를 **병합**한다. 원본 링크에 이미 쿼리스트링이 있으므로 +단순 문자열 결합이 아니다. 클릭 로그는 비동기로 적재해 리다이렉트를 DB 쓰기가 붙잡지 않게 한다. + +코드가 없거나 만료면 404 대신 랜딩 페이지로 302한다. 사용자에게 실패를 보이지 않는다. + +**랜딩** — `GET /` (Host: `ad.picke.store`) → Thymeleaf `ad/landing` + +ACTIVE 소재를 카드로 나열하고 하단에 쿠팡 파트너스 수수료 고지 문구를 넣는다. +쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로, 빈 페이지면 반려된다. + +Host가 광고 도메인이 아니면 최소 응답만 돌려준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + +**어드민** — `/api/v1/admin/ads` CRUD, `/api/v1/admin/ads/stats?from=&to=` +이미지 업로드는 기존 S3 presigned 경로를 재사용한다. + +### 3.6 매체별 링크 빌더 + +`AffiliateLinkBuilder` 인터페이스 하나에 매체별 구현체를 둔다. + +- `CoupangLinkBuilder` — `subId={slot}_{code}` 병합. 파트너스 리포트에서 지면별 실매출이 갈린다. +- `AdpickLinkBuilder` — 파라미터명을 `picke.ad.adpick.sub-id-param` 설정값으로 둔다. + 서브아이디 규격을 아직 확인하지 못해 기본값은 비어 있고, 그동안은 pass-through로 원본 링크를 넘긴다. + 파트너센터 링크생성 화면에서 규격이 확인되면 **배포 없이 환경변수만 채우면** 쿠팡과 같은 방식으로 붙는다. + 그전까지 애드픽은 지면별 성과 분리가 안 될 뿐, 노출·클릭·리다이렉트는 정상 동작한다. + +### 3.5.1 애드픽 캠페인 자동 수급 + +애드픽은 캠페인 리스트 JSON API(`offers.php`)를 인증 없이 공개한다. 쿠팡 오픈API와 달리 승인 절차가 없어 +애드픽 소재는 사람이 등록하지 않고 주기 동기화로 채운다. + +수집한 캠페인은 **같은 `ad_creatives` 테이블에 `source = ADPICK_API`로 저장한다.** 별도 테이블을 두지 않으므로 +로테이션·노출 집계·클릭 추적 경로를 그대로 탄다. + +응답에서 쓰는 필드는 `apOffer`(캠페인 ID), `apAppTitle`, `apHeadline`/`apAppPromoText`, `apImages.icon`, +`apTrackingLink`, `apOS`, `apRemain`이다. + +호출 제약이 있다. 애드픽 가이드가 **최대 1분에 1회 이하 호출과 저장 후 사용**을 요구하고, 실제로 짧은 간격으로 +연달아 호출하면 403을 돌려준다. 그래서 요청 때마다 부르지 않고 스케줄러로만 부른다. + +동기화 규칙은 이렇다. + +- `apRemain`이 0이면 게재하지 않는다. 잔여가 없는 캠페인은 클릭해도 전환이 잡히지 않는다. +- 피드에서 사라진 캠페인은 지우지 않고 내린다. 쌓인 노출·클릭 집계가 어느 소재의 것인지 계속 읽혀야 한다. +- **`PAUSED`는 동기화가 되돌리지 않는다.** 별도 플래그 없이 어드민의 끄기 스위치로 쓴다. +- 동기화가 내용을 덮어쓰므로 어드민에서 수정·삭제는 막고, 게재 상태만 바꾸게 한다. + +`affId`(애드픽 회원 아이디)가 비어 있으면 동기화를 건너뛴다. 가입 전에도 나머지 기능은 그대로 돈다. + +### 3.5.2 OS 타깃팅 + +애드픽 앱 설치형 캠페인은 `apOS`로 OS가 갈린다. iOS 사용자에게 Android 캠페인을 보여주면 클릭해도 +전환이 일어나지 않으므로, 소재에 `target_os`를 두고 조회 시 요청 OS와 맞는 것만 준다. + +`GET /api/v1/ads`에 `os` 파라미터가 필요하다. 값을 안 보내면 `ALL`로 보고 모든 소재를 후보로 둔다. + +### 3.6.1 쿠팡 파트너스 아이디 대조 + +남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. +소재 등록·수정 시 `landingUrl`의 `lptag`를 `coupang.partners.id`와 대조해 다르면 거부한다. + +다만 `link.coupang.com` 단축 링크에는 `lptag`가 드러나지 않으므로 **파라미터가 있을 때만** 본다. +없다고 막으면 정상적인 단축 링크를 쓸 수 없다. + +### 3.7 인증 우회 경로 + +`JwtFilter`가 SecurityConfig보다 먼저 돌면서 **토큰이 없으면 무조건 401**을 던진다. +따라서 `SecurityConfig.permitAll`만으로는 공개 엔드포인트가 뚫리지 않고, `JwtFilter.WHITELIST`에도 넣어야 한다. + +그런데 `isWhitelisted`가 `startsWith` 매칭이라 `"/"`를 넣으면 전체 인증이 무력화된다. +**정확히 일치할 때만 통과하는 `EXACT_WHITELIST`를 분리해 `/`와 `/error`를 넣는다.** + +`/error`가 빠져 있던 탓에 존재하지 않는 모든 경로가 404 대신 401로 나오고 있었다. 같이 고친다. + +### 3.8 Swagger 분리 + +광고 API는 별도 그룹 `3. 광고 API`로 띄운다. `/api/v1/ads/**`, `/api/v1/admin/ads/**`를 매칭하고, +기존 사용자·관리자 그룹에서는 제외해 섞이지 않게 한다. + +기존 `userApi` 그룹은 `FE_USED_OPERATIONS` 화이트리스트로 필터링되므로, 광고 API를 거기 넣으면 +어차피 보이지 않는다. 별도 그룹이 구조적으로 맞다. + +## 4. 운영 선행 작업 + +| 항목 | 상태 | +| --- | --- | +| `ad.picke.store` DNS + Railway 커스텀 도메인 + TLS | 완료 | +| 루트 공개 지면 배포 | 본 구현에 포함 | +| 광고 테이블 생성 | 별도 실행 불필요. `ddl-auto: update`라 배포 시 자동 생성된다 | +| 쿠팡 파트너스 가입·매체 등록 | ID 발급됨(`AF6830373`), 매체 등록 확인 필요 | +| 애드픽 파트너 가입 | 미착수. 계정이 필요해 코드로 대신할 수 없다 | +| 애드픽 `affId` 주입 | 가입 후 `ADPICK_AFF_ID` 설정. 비어 있으면 동기화를 건너뛴다 | +| 애드픽 서브아이디 파라미터 규격 확인 | 미확인. 확인되면 `ADPICK_SUB_ID_PARAM` 환경변수만 채우면 된다 | +| 애드픽 이용정책상 자체 앱 배너 노출 허용 여부 | 미확인 | +| 지면 목록 앱팀 확정 | 후보 5개 확정, 앱팀 확인 대기 | +| Play Console / App Store Connect "광고 포함" 신고 | 미착수 | +| 개인정보처리방침에 제휴 광고 문구 추가 | 미착수 | + +## 5. 지면 배치 트레이드오프 + +애드픽 캠페인 상당수가 CPI(앱 설치형)다. 단가는 커머스보다 높지만 클릭하면 사용자가 스토어로 나가 +다른 앱을 설치한다. 배틀 진행 중간 지면에 CPI를 깔면 이탈·리텐션에 직접 타격이 온다. + +세션이 자연스럽게 끝나는 지점(배틀 결과 화면)에 CPI를 두고, 피드 중간 인라인은 이탈 부담이 적은 +쿠팡 커머스로 채우는 배치를 권한다. 데이터가 쌓이면 지면별 CTR과 정산액을 보고 조정한다. + +## 6. 테스트 + +실제로 깨지기 쉬운 지점에 집중한다. + +- 쿼리스트링이 이미 붙은 제휴 링크에 `subId` 병합 +- 기간·상태 필터링 (미시작/만료/PAUSED 소재가 노출되지 않을 것) +- 가중 로테이션 분포 +- 없는 code / 만료 code 클릭 시 랜딩으로 302 +- 노출 집계 upsert가 같은 날 중복 호출에 누적될 것 + +## 7. 배포 체크리스트 + +- **광고 enum 을 바꿨으면 CHECK 제약을 직접 ALTER 한다.** `ddl-auto=update` 는 이미 있는 제약을 + 갱신하지 않아, `AdSlotCode`·`AdNetwork`·`AdStatus`·`AdSource`·`AdTargetOs` 에 값을 추가하고 배포하면 + 새 값의 INSERT 가 운영에서 조용히 막힌다. 구문은 `docs/db/20260901_create_ad_tables.sql` 주석에 있다. + `AdCheckConstraintTest` 가 CI 에서 먼저 깨주지만, 실제 ALTER 실행은 사람이 해야 한다. +- `COUPANG_PARTNERS_ID` 를 설정한다. 기본값이 없어 비어 있으면 기동 자체가 실패한다. +- `ADPICK_AFF_ID` 를 설정한다. 비어 있으면 애드픽 동기화만 건너뛰고 나머지는 정상 동작한다. diff --git a/hs_err_pid172944.log b/hs_err_pid172944.log deleted file mode 100644 index 8707abc3..00000000 --- a/hs_err_pid172944.log +++ /dev/null @@ -1,281 +0,0 @@ -# -# There is insufficient memory for the Java Runtime Environment to continue. -# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space -# Possible reasons: -# The system is out of physical RAM or swap space -# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap -# Possible solutions: -# Reduce memory load on the system -# Increase physical memory or swap space -# Check if swap backing store is full -# Decrease Java heap size (-Xmx/-Xms) -# Decrease number of Java threads -# Decrease Java thread stack sizes (-Xss) -# Set larger code cache with -XX:ReservedCodeCacheSize= -# JVM is running with Zero Based Compressed Oops mode in which the Java heap is -# placed in the first 32GB address space. The Java Heap base address is the -# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress -# to set the Java Heap base and to place the Java Heap above 32GB virtual address. -# This output file may be truncated or incomplete. -# -# Out of Memory Error (os_windows.cpp:3714), pid=172944, tid=186552 -# -# JRE version: (21.0.10+7) (build ) -# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) -# No core dump will be written. Minidumps are not enabled by default on client versions of Windows -# - ---------------- S U M M A R Y ------------ - -Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=51664 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication - -Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -Time: Mon Mar 30 22:58:11 2026 elapsed time: 1.695367 seconds (0d 0h 0m 1s) - ---------------- T H R E A D --------------- - -Current thread (0x0000024510954d30): JavaThread "Unknown thread" [_thread_in_vm, id=186552, stack(0x0000007972f00000,0x0000007973000000) (1024K)] - -Stack: [0x0000007972f00000,0x0000007973000000] -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -V [jvm.dll+0x6df2b9] -V [jvm.dll+0x8bbdeb] -V [jvm.dll+0x8be37a] -V [jvm.dll+0x8bea53] -V [jvm.dll+0x28a7a6] -V [jvm.dll+0x6dbc15] -V [jvm.dll+0x6cfbca] -V [jvm.dll+0x364f6e] -V [jvm.dll+0x36ce3b] -V [jvm.dll+0x3be8d9] -V [jvm.dll+0x3beb7b] -V [jvm.dll+0x339137] -V [jvm.dll+0x339c7b] -V [jvm.dll+0x88634e] -V [jvm.dll+0x3cb831] -V [jvm.dll+0x86f25c] -V [jvm.dll+0x45e901] -V [jvm.dll+0x460541] -C [jli.dll+0x52f0] -C [ucrtbase.dll+0x37b0] -C [KERNEL32.DLL+0x2e8d7] -C [ntdll.dll+0x8c48c] - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x00007ffa227e2208, length=0, elements={ -} - -Java Threads: ( => current thread ) -Total: 0 - -Other Threads: - 0x00000245266f03f0 WorkerThread "GC Thread#0" [id=134476, stack(0x0000007973000000,0x0000007973100000) (1024K)] - 0x00000245109ce130 ConcurrentGCThread "G1 Main Marker" [id=195520, stack(0x0000007973100000,0x0000007973200000) (1024K)] - 0x00000245109d0570 WorkerThread "G1 Conc#0" [id=133400, stack(0x0000007973200000,0x0000007973300000) (1024K)] - -[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] -VM state: not at safepoint (not fully initialized) - -VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) -[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000024510954d30 - -Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. -Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 - -GC Precious Log: - CardTable entry size: 512 - Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 - -Heap: - garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) - region size 4096K, 0 young (0K), 0 survivors (0K) - -[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] -GC Heap History (0 events): -No events - -Dll operation events (1 events): -Event: 0.007 Loaded shared library D:\application\.jdk\bin\java.dll - -Deoptimization events (0 events): -No events - -Classes loaded (0 events): -No events - -Classes unloaded (0 events): -No events - -Classes redefined (0 events): -No events - -Internal exceptions (0 events): -No events - -ZGC Phase Switch (0 events): -No events - -VM Operations (0 events): -No events - -Memory protections (0 events): -No events - -Nmethod flushes (0 events): -No events - -Events (0 events): -No events - - -Dynamic libraries: -0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe -0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll -0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL -0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll -0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll -0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll -0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll -0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll -0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll -0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll -0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll -0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll -0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll -0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll -0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL -0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll -0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll -0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll -0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll -0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll -0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll -0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll -0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll -0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll -0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll -0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll -0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll -0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll -0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL -0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll -0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll -0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL -0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll -0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll -0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll - -JVMTI agents: -C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:51664 - -dbghelp: loaded successfully - version: 4.0.5 - missing functions: none -symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server - -VM Arguments: -jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=51664 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -java_command: com.swyp.picke.PickeApplication -java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 12 {product} {ergonomic} - uint ConcGCThreads = 4 {product} {ergonomic} - uint G1ConcRefinementThreads = 14 {product} {ergonomic} - size_t G1HeapRegionSize = 4194304 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 532676608 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 8480882688 {product} {ergonomic} - size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags foldmultilines=false - #1: stderr all=off uptime,level,tags foldmultilines=false - -Release file: - -Environment Variables: -JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 -CLASSPATH=%JAVA_HOME%\lib -PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm -USERNAME=guswn -OS=Windows_NT -PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel -TMP=C:\Users\guswn\AppData\Local\Temp -TEMP=C:\Users\guswn\AppData\Local\Temp - - - - -Periodic native trim disabled - ---------------- S Y S T E M --------------- - -OS: - Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -OS uptime: 16 days 18:33 hours -Hyper-V role detected - -CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss -Processor Information for processor 0 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 1 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 2 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 3 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 4 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 5 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 6 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 7 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 8 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 9 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 10 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 11 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 12 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 13 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 14 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 15 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 16 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 -Processor Information for processor 17 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 - -Memory: 4k page, system-wide physical 32346M (3876M free) -TotalPageFile size 61970M (AvailPageFile size 336M) -current process WorkingSet (physical memory assigned to process): 13M, peak: 13M -current process commit charge ("private bytes"): 68M, peak: 576M - -vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) - -END. diff --git a/hs_err_pid189152.log b/hs_err_pid189152.log deleted file mode 100644 index 12c25b95..00000000 --- a/hs_err_pid189152.log +++ /dev/null @@ -1,281 +0,0 @@ -# -# There is insufficient memory for the Java Runtime Environment to continue. -# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space -# Possible reasons: -# The system is out of physical RAM or swap space -# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap -# Possible solutions: -# Reduce memory load on the system -# Increase physical memory or swap space -# Check if swap backing store is full -# Decrease Java heap size (-Xmx/-Xms) -# Decrease number of Java threads -# Decrease Java thread stack sizes (-Xss) -# Set larger code cache with -XX:ReservedCodeCacheSize= -# JVM is running with Zero Based Compressed Oops mode in which the Java heap is -# placed in the first 32GB address space. The Java Heap base address is the -# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress -# to set the Java Heap base and to place the Java Heap above 32GB virtual address. -# This output file may be truncated or incomplete. -# -# Out of Memory Error (os_windows.cpp:3714), pid=189152, tid=195204 -# -# JRE version: (21.0.10+7) (build ) -# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) -# No core dump will be written. Minidumps are not enabled by default on client versions of Windows -# - ---------------- S U M M A R Y ------------ - -Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55101 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication - -Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -Time: Mon Mar 30 22:58:27 2026 elapsed time: 2.032925 seconds (0d 0h 0m 2s) - ---------------- T H R E A D --------------- - -Current thread (0x0000027bde915cc0): JavaThread "Unknown thread" [_thread_in_vm, id=195204, stack(0x00000086bb400000,0x00000086bb500000) (1024K)] - -Stack: [0x00000086bb400000,0x00000086bb500000] -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -V [jvm.dll+0x6df2b9] -V [jvm.dll+0x8bbdeb] -V [jvm.dll+0x8be37a] -V [jvm.dll+0x8bea53] -V [jvm.dll+0x28a7a6] -V [jvm.dll+0x6dbc15] -V [jvm.dll+0x6cfbca] -V [jvm.dll+0x364f6e] -V [jvm.dll+0x36ce3b] -V [jvm.dll+0x3be8d9] -V [jvm.dll+0x3beb7b] -V [jvm.dll+0x339137] -V [jvm.dll+0x339c7b] -V [jvm.dll+0x88634e] -V [jvm.dll+0x3cb831] -V [jvm.dll+0x86f25c] -V [jvm.dll+0x45e901] -V [jvm.dll+0x460541] -C [jli.dll+0x52f0] -C [ucrtbase.dll+0x37b0] -C [KERNEL32.DLL+0x2e8d7] -C [ntdll.dll+0x8c48c] - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x00007ffa227e2208, length=0, elements={ -} - -Java Threads: ( => current thread ) -Total: 0 - -Other Threads: - 0x0000027bf46a03f0 WorkerThread "GC Thread#0" [id=7472, stack(0x00000086bb500000,0x00000086bb600000) (1024K)] - 0x0000027bde990120 ConcurrentGCThread "G1 Main Marker" [id=163140, stack(0x00000086bb600000,0x00000086bb700000) (1024K)] - 0x0000027bde991760 WorkerThread "G1 Conc#0" [id=152476, stack(0x00000086bb700000,0x00000086bb800000) (1024K)] - -[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] -VM state: not at safepoint (not fully initialized) - -VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) -[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000027bde915cc0 - -Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. -Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 - -GC Precious Log: - CardTable entry size: 512 - Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 - -Heap: - garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) - region size 4096K, 0 young (0K), 0 survivors (0K) - -[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] -GC Heap History (0 events): -No events - -Dll operation events (1 events): -Event: 0.005 Loaded shared library D:\application\.jdk\bin\java.dll - -Deoptimization events (0 events): -No events - -Classes loaded (0 events): -No events - -Classes unloaded (0 events): -No events - -Classes redefined (0 events): -No events - -Internal exceptions (0 events): -No events - -ZGC Phase Switch (0 events): -No events - -VM Operations (0 events): -No events - -Memory protections (0 events): -No events - -Nmethod flushes (0 events): -No events - -Events (0 events): -No events - - -Dynamic libraries: -0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe -0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll -0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL -0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll -0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll -0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll -0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll -0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll -0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll -0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll -0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll -0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll -0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll -0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll -0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL -0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll -0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll -0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll -0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll -0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll -0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll -0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll -0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll -0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll -0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll -0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll -0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll -0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll -0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL -0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll -0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll -0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL -0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll -0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll -0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll - -JVMTI agents: -C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:55101 - -dbghelp: loaded successfully - version: 4.0.5 - missing functions: none -symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server - -VM Arguments: -jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55101 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -java_command: com.swyp.picke.PickeApplication -java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 12 {product} {ergonomic} - uint ConcGCThreads = 4 {product} {ergonomic} - uint G1ConcRefinementThreads = 14 {product} {ergonomic} - size_t G1HeapRegionSize = 4194304 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 532676608 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 8480882688 {product} {ergonomic} - size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags foldmultilines=false - #1: stderr all=off uptime,level,tags foldmultilines=false - -Release file: - -Environment Variables: -JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 -CLASSPATH=%JAVA_HOME%\lib -PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm -USERNAME=guswn -OS=Windows_NT -PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel -TMP=C:\Users\guswn\AppData\Local\Temp -TEMP=C:\Users\guswn\AppData\Local\Temp - - - - -Periodic native trim disabled - ---------------- S Y S T E M --------------- - -OS: - Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -OS uptime: 16 days 18:33 hours -Hyper-V role detected - -CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss -Processor Information for processor 0 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 1 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 2 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 3 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 4 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 5 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 6 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 7 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 8 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 9 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 10 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 11 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 12 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 13 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 14 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 15 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 16 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 -Processor Information for processor 17 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 - -Memory: 4k page, system-wide physical 32346M (3826M free) -TotalPageFile size 61970M (AvailPageFile size 310M) -current process WorkingSet (physical memory assigned to process): 13M, peak: 13M -current process commit charge ("private bytes"): 68M, peak: 576M - -vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) - -END. diff --git a/hs_err_pid192028.log b/hs_err_pid192028.log deleted file mode 100644 index cd8d3a59..00000000 --- a/hs_err_pid192028.log +++ /dev/null @@ -1,281 +0,0 @@ -# -# There is insufficient memory for the Java Runtime Environment to continue. -# Native memory allocation (mmap) failed to map 532676608 bytes. Error detail: G1 virtual space -# Possible reasons: -# The system is out of physical RAM or swap space -# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap -# Possible solutions: -# Reduce memory load on the system -# Increase physical memory or swap space -# Check if swap backing store is full -# Decrease Java heap size (-Xmx/-Xms) -# Decrease number of Java threads -# Decrease Java thread stack sizes (-Xss) -# Set larger code cache with -XX:ReservedCodeCacheSize= -# JVM is running with Zero Based Compressed Oops mode in which the Java heap is -# placed in the first 32GB address space. The Java Heap base address is the -# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress -# to set the Java Heap base and to place the Java Heap above 32GB virtual address. -# This output file may be truncated or incomplete. -# -# Out of Memory Error (os_windows.cpp:3714), pid=192028, tid=189352 -# -# JRE version: (21.0.10+7) (build ) -# Java VM: OpenJDK 64-Bit Server VM (21.0.10+7-LTS, mixed mode, emulated-client, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) -# No core dump will be written. Minidumps are not enabled by default on client versions of Windows -# - ---------------- S U M M A R Y ------------ - -Command Line: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55095 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 com.swyp.picke.PickeApplication - -Host: Intel(R) Core(TM) Ultra 5 125H, 18 cores, 31G, Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -Time: Mon Mar 30 22:58:20 2026 elapsed time: 2.059801 seconds (0d 0h 0m 2s) - ---------------- T H R E A D --------------- - -Current thread (0x0000012fb4ed3030): JavaThread "Unknown thread" [_thread_in_vm, id=189352, stack(0x00000005f8500000,0x00000005f8600000) (1024K)] - -Stack: [0x00000005f8500000,0x00000005f8600000] -Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) -V [jvm.dll+0x6df2b9] -V [jvm.dll+0x8bbdeb] -V [jvm.dll+0x8be37a] -V [jvm.dll+0x8bea53] -V [jvm.dll+0x28a7a6] -V [jvm.dll+0x6dbc15] -V [jvm.dll+0x6cfbca] -V [jvm.dll+0x364f6e] -V [jvm.dll+0x36ce3b] -V [jvm.dll+0x3be8d9] -V [jvm.dll+0x3beb7b] -V [jvm.dll+0x339137] -V [jvm.dll+0x339c7b] -V [jvm.dll+0x88634e] -V [jvm.dll+0x3cb831] -V [jvm.dll+0x86f25c] -V [jvm.dll+0x45e901] -V [jvm.dll+0x460541] -C [jli.dll+0x52f0] -C [ucrtbase.dll+0x37b0] -C [KERNEL32.DLL+0x2e8d7] -C [ntdll.dll+0x8c48c] - - ---------------- P R O C E S S --------------- - -Threads class SMR info: -_java_thread_list=0x00007ffa227e2208, length=0, elements={ -} - -Java Threads: ( => current thread ) -Total: 0 - -Other Threads: - 0x0000012fcd0203f0 WorkerThread "GC Thread#0" [id=193216, stack(0x00000005f8600000,0x00000005f8700000) (1024K)] - 0x0000012fb7315ca0 ConcurrentGCThread "G1 Main Marker" [id=154308, stack(0x00000005f8700000,0x00000005f8800000) (1024K)] - 0x0000012fb73180e0 WorkerThread "G1 Conc#0" [id=191576, stack(0x00000005f8800000,0x00000005f8900000) (1024K)] - -[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa21edbbb7] -VM state: not at safepoint (not fully initialized) - -VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event]) -[0x00007ffa228566b0] Heap_lock - owner thread: 0x0000012fb4ed3030 - -Heap address: 0x0000000606800000, size: 8088 MB, Compressed Oops mode: Zero based, Oop shift amount: 3 - -CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1. -Narrow klass base: 0x0000000000000000, Narrow klass shift: 0, Narrow klass range: 0x0 - -GC Precious Log: - CardTable entry size: 512 - Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 32 size 80 Howl #buckets 8 coarsen threshold 7372 Howl Bitmap #cards 1024 size 144 coarsen threshold 921 Card regions per heap region 1 cards per card region 8192 - -Heap: - garbage-first heap total 0K, used 0K [0x0000000606800000, 0x0000000800000000) - region size 4096K, 0 young (0K), 0 survivors (0K) - -[error occurred during error reporting (printing heap information), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffa222c9019] -GC Heap History (0 events): -No events - -Dll operation events (1 events): -Event: 0.044 Loaded shared library D:\application\.jdk\bin\java.dll - -Deoptimization events (0 events): -No events - -Classes loaded (0 events): -No events - -Classes unloaded (0 events): -No events - -Classes redefined (0 events): -No events - -Internal exceptions (0 events): -No events - -ZGC Phase Switch (0 events): -No events - -VM Operations (0 events): -No events - -Memory protections (0 events): -No events - -Nmethod flushes (0 events): -No events - -Events (0 events): -No events - - -Dynamic libraries: -0x00007ff7fbf60000 - 0x00007ff7fbf6e000 D:\application\.jdk\bin\java.exe -0x00007ffb03b00000 - 0x00007ffb03d67000 C:\WINDOWS\SYSTEM32\ntdll.dll -0x00007ffb02ce0000 - 0x00007ffb02da9000 C:\WINDOWS\System32\KERNEL32.DLL -0x00007ffb00fa0000 - 0x00007ffb01391000 C:\WINDOWS\System32\KERNELBASE.dll -0x00007ffb003e0000 - 0x00007ffb0052b000 C:\WINDOWS\System32\ucrtbase.dll -0x00007ffaeab10000 - 0x00007ffaeab2e000 D:\application\.jdk\bin\VCRUNTIME140.dll -0x00007ffae63b0000 - 0x00007ffae63c8000 D:\application\.jdk\bin\jli.dll -0x00007ffb02db0000 - 0x00007ffb02f75000 C:\WINDOWS\System32\USER32.dll -0x00007ffae7290000 - 0x00007ffae7523000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b\COMCTL32.dll -0x00007ffb015c0000 - 0x00007ffb015e7000 C:\WINDOWS\System32\win32u.dll -0x00007ffb031a0000 - 0x00007ffb03249000 C:\WINDOWS\System32\msvcrt.dll -0x00007ffb02780000 - 0x00007ffb027ab000 C:\WINDOWS\System32\GDI32.dll -0x00007ffb01770000 - 0x00007ffb0189b000 C:\WINDOWS\System32\gdi32full.dll -0x00007ffb005f0000 - 0x00007ffb00693000 C:\WINDOWS\System32\msvcp_win.dll -0x00007ffb03160000 - 0x00007ffb03191000 C:\WINDOWS\System32\IMM32.DLL -0x00007ffaecfa0000 - 0x00007ffaecfac000 D:\application\.jdk\bin\vcruntime140_1.dll -0x00007ffad39c0000 - 0x00007ffad3a49000 D:\application\.jdk\bin\msvcp140.dll -0x00007ffa21b90000 - 0x00007ffa22938000 D:\application\.jdk\bin\server\jvm.dll -0x00007ffb01d30000 - 0x00007ffb01deb000 C:\WINDOWS\System32\ADVAPI32.dll -0x00007ffb03250000 - 0x00007ffb032f7000 C:\WINDOWS\System32\sechost.dll -0x00007ffb024a0000 - 0x00007ffb025b8000 C:\WINDOWS\System32\RPCRT4.dll -0x00007ffb029a0000 - 0x00007ffb02a14000 C:\WINDOWS\System32\WS2_32.dll -0x00007ffb00240000 - 0x00007ffb0029e000 C:\WINDOWS\SYSTEM32\POWRPROF.dll -0x00007ffaec6c0000 - 0x00007ffaec6f5000 C:\WINDOWS\SYSTEM32\WINMM.dll -0x00007ffae7a30000 - 0x00007ffae7a3b000 C:\WINDOWS\SYSTEM32\VERSION.dll -0x00007ffb00220000 - 0x00007ffb00234000 C:\WINDOWS\SYSTEM32\UMPDC.dll -0x00007ffaff170000 - 0x00007ffaff18b000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll -0x00007ffaea260000 - 0x00007ffaea26a000 D:\application\.jdk\bin\jimage.dll -0x00007ffafe4f0000 - 0x00007ffafe732000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL -0x00007ffb01e50000 - 0x00007ffb021d2000 C:\WINDOWS\System32\combase.dll -0x00007ffb02f80000 - 0x00007ffb03057000 C:\WINDOWS\System32\OLEAUT32.dll -0x00007ffada3d0000 - 0x00007ffada40b000 C:\WINDOWS\SYSTEM32\dbgcore.DLL -0x00007ffb013a0000 - 0x00007ffb01445000 C:\WINDOWS\System32\bcryptPrimitives.dll -0x00007ffae63a0000 - 0x00007ffae63b0000 D:\application\.jdk\bin\instrument.dll -0x00007ffae6310000 - 0x00007ffae6331000 D:\application\.jdk\bin\java.dll - -JVMTI agents: -C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar path:none, loaded, not initialized, instrumentlib options:55095 - -dbghelp: loaded successfully - version: 4.0.5 - missing functions: none -symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;D:\application\.jdk\bin;C:\WINDOWS\SYSTEM32;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8037_none_3e092faae333b53b;D:\application\.jdk\bin\server - -VM Arguments: -jvm_args: -XX:TieredStopAtLevel=1 -Dspring.profiles.active=local -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -javaagent:C:\Users\guswn\AppData\Local\Programs\IntelliJ IDEA\lib\idea_rt.jar=55095 -Dfile.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -java_command: com.swyp.picke.PickeApplication -java_class_path (initial): D:\Desktop\project\Server\build\classes\java\main;D:\Desktop\project\Server\build\resources\main;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.projectlombok\lombok\1.18.42\8365263844ebb62398e0dc33057ba10ba472d3b8\lombok-1.18.42.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-web\3.5.11\68fde4c94249e92526105a93ac7c22bd89b6945e\spring-boot-starter-web-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-ui\2.8.16\61c68f705d3f17e8318fb18b2904fa6368af251c\springdoc-openapi-starter-webmvc-ui-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-validation\3.5.11\903eefb6eab302617b0f01cc6d65664343bff2a7\spring-boot-starter-validation-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-data-jpa\3.5.11\f176e5c643720818ec7910e1dd2ccb402411cc5d\spring-boot-starter-data-jpa-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-security\3.5.11\db8b6b7951883dea3ce7404f20d6816104cedd4e\spring-boot-starter-security-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-api\0.12.6\478886a888f6add04937baf0361144504a024967\jjwt-api-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-webflux\3.5.11\1461f9a6b6b8397ad71a98c9bbf4278159fb9624\spring-boot-starter-webflux-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\apps-rewardedads\1.9.1\cbaf11457b36fe57d90a5cb16a76833906486503\apps-rewardedads-1.9.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.cloud\google-cloud-texttospeech\2.58.0\9be37bd3c81c14c72c9cbcfa2dfaf6dad7a35075\google-cloud-texttospeech-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter-s3\3.3.0\fa9790f990ab540814aafdbf2e97c8cd53b5b1a6\spring-cloud-aws-starter-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-thymeleaf\3.5.11\d997aa0df579cf43507d425057940e2712e44808\spring-boot-starter-thymeleaf-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-json\3.5.11\4cdcd68dcddf0a4c645166e39c3fe448fe2b8e98\spring-boot-starter-json-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter\3.5.11\10ce971300fd56d6be5f1cfe7d27ddfb1ed7158d\spring-boot-starter-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-tomcat\3.5.11\fb7b96cb61e5fd5700aed96194562e32d166b5ef\spring-boot-starter-tomcat-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webmvc\6.2.16\ff2db80406f1459fddd14a8d06d57e0e3ab69465\spring-webmvc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-web\6.2.16\2c4355f1f7e5b8969f696cbc90f25cc22f0f2164\spring-web-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-webmvc-api\2.8.16\6e41988d84978e529c01a4cc052b761cd27d5b90\springdoc-openapi-starter-webmvc-api-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\swagger-ui\5.32.0\d04c7e3e5b8616813136fa36382a548751775528\swagger-ui-5.32.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.webjars\webjars-locator-lite\1.1.3\217ce590453251b39b72c4a9af3986998f6fdbd9\webjars-locator-lite-1.1.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-el\10.1.52\cd94ce17c5a9937eca365eb494711efa10d49b86\tomcat-embed-el-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.validator\hibernate-validator\8.0.3.Final\4425f554297a1c5ba03a3f30e559a9fd91048cf8\hibernate-validator-8.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-jdbc\3.5.11\3ce801963caadf6eb29abd68f5a0fe50c9bfe211\spring-boot-starter-jdbc-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.orm\hibernate-core\6.6.42.Final\996e3df4a6c67941b582e4493cb9a39c83198f1e\hibernate-core-6.6.42.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-jpa\3.5.9\56081dde4f663db74ba000c1f8ab30673058c363\spring-data-jpa-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aspects\6.2.16\763140a66821c494985533f29280a3b4132cf055\spring-aspects-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-web\6.5.8\3db7bf41191d5b23493cca6252595405b5112b34\spring-security-web-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-config\6.5.8\302d32eba89131c0ffd15ba0a1e465051336d42f\spring-security-config-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-aop\6.2.16\59250efa248420a114fe23b4ccf2fea46b804186\spring-aop-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-webflux\6.2.16\699ef8bc182893f9ed43206d372f20c4f9aa3231\spring-webflux-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-reactor-netty\3.5.11\f98bd3e3019078679dec4f21fb63152aa2e059a7\spring-boot-starter-reactor-netty-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.crypto.tink\tink\1.10.0\84771b1a4bb5726f73fb8490fadb23f1d2aacd38\tink-1.10.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.gson\gson\2.13.2\48b8230771e573b54ce6e867a9001e75977fe78e\gson-2.13.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client\1.45.3\dde98b597081b98514867c9cefa551fcdea3a28c\google-http-client-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.errorprone\error_prone_annotations\2.41.0\4381275efdef6ddfae38f002c31e84cd001c97f0\error_prone_annotations-2.41.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-contrib-http-util\0.31.1\3c13fc5715231fadb16a9b74a44d9d59c460cfa8\opencensus-contrib-http-util-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.4.0-jre\3fcc0a259f724c7de54a6a55ea7e26d3d5c0cac\guava-33.4.0-jre.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-api\1.69.0\965c2c7f708cd6e6ddbf1eb175c3e87e96e41297\grpc-api-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.code.findbugs\jsr305\3.0.2\25ea2e8b0c338a877313bd4672d3fe056ea78f0d\jsr305-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-stub\1.69.0\9e7dc30a9c2df70e25ef4b941f46187e6e178e7a\grpc-stub-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf\1.69.0\2990b4948357d4fe46aaecb47290cff102079f1e\grpc-protobuf-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\api-common\2.43.0\963d97d95e9bf7275cc26f0b6b72e2aa5b92c6fd\api-common-2.43.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auto.value\auto-value-annotations\1.11.0\f0d047931d07cfbc6fa4079854f181ff62891d6f\auto-value-annotations-1.11.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\javax.annotation\javax.annotation-api\1.3.2\934c04d3cfef185a8008e7bf34331b79730a9d43\javax.annotation-api-1.3.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.j2objc\j2objc-annotations\3.0.0\7399e65dd7e9ff3404f4535b2f017093bdb134c7\j2objc-annotations-3.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java\3.25.5\5ae5c9ec39930ae9b5a61b32b93288818ec05ec1\protobuf-java-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-common-protos\2.51.0\ead75a32e6fd65740b6a69feb658254aeab3fef0\proto-google-common-protos-2.51.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1\2.58.0\42f1f29876ddfa2523ebcc41dae801195fd8b3ce\proto-google-cloud-texttospeech-v1-2.58.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api.grpc\proto-google-cloud-texttospeech-v1beta1\0.147.0\576df432a2c1181deabf21a54fdecc1a32f69f4e\proto-google-cloud-texttospeech-v1beta1-0.147.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.48.4\6b5d69a61012211d581e68699baf3beb1fd382da\checker-qual-3.48.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax\2.60.0\2d277e0795cb69bc14e03be068aa002539e3ef49\gax-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-credentials\1.31.0\b9cd5346d3a683d9a8d9786453f2419cc832a97f\google-auth-library-credentials-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.opencensus\opencensus-api\0.31.1\66a60c7201c2b8b20ce495f0295b32bb0ccbbc57\opencensus-api-0.31.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-context\1.69.0\cea23878872f76418dcd6df0c6eef0bf27463537\grpc-context-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.auth\google-auth-library-oauth2-http\1.31.0\df5be46d21b983aab8d0250f19b585a94bdedcde\google-auth-library-oauth2-http-1.31.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-grpc\2.60.0\ca4d7dc8c2a85fbdba25ff3449726852e2359ae9\gax-grpc-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-inprocess\1.69.0\8ac4d2e13b48bed9624b8bc485c90f3d28820c93\grpc-inprocess-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-core\1.69.0\7dad3419dfb91a77788afcdf79e0477172784910\grpc-core-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-alts\1.69.0\6d1eac6726fd6fd177666c10fd154823b82272eb\grpc-alts-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-grpclb\1.69.0\d2c9c066693ce94805a503bc47f5b1e76f51541c\grpc-grpclb-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.conscrypt\conscrypt-openjdk-uber\2.5.2\d858f142ea189c62771c505a6548d8606ac098fe\conscrypt-openjdk-uber-2.5.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-auth\1.69.0\a75e19b20bb732364bdcc0979e9d7c9baa4e408e\grpc-auth-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-netty-shaded\1.69.0\99aa9789172695a4b09fe2af5f5bd0ab1be4ae85\grpc-netty-shaded-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.api\gax-httpjson\2.60.0\131d9283925337406e35561ec17bf326a9ecec1a\gax-httpjson-2.60.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpclient\4.5.14\1194890e6f56ec29177673f2f12d0b8e627dec98\httpclient-4.5.14.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\commons-codec\commons-codec\1.18.0\ee45d1cf6ec2cc2b809ff04b4dc7aec858e0df8f\commons-codec-1.18.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.httpcomponents\httpcore\4.4.16\51cf043c87253c9f58b539c9f7e44c8894223850\httpcore-4.4.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.http-client\google-http-client-gson\1.45.3\29eba40245c4a4e5466f8764bd894d6a97c6694f\google-http-client-gson-1.45.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.protobuf\protobuf-java-util\3.25.5\38cc5ce479603e36466feda2a9f1dfdb2210ef00\protobuf-java-util-3.25.5.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.threeten\threetenbp\1.7.0\8703e893440e550295aa358281db468625bc9a05\threetenbp-1.7.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-starter\3.3.0\7d82d320cb1851beca3005eab2e484a38bd58a08\spring-cloud-aws-starter-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-s3\3.3.0\661e2914e3ad6555e20ffa262a9987c15bcc1712\spring-cloud-aws-s3-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\slf4j-api\2.0.17\d9e58ac9c7779ba3bf8142aff6c830617a7fe60f\slf4j-api-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf-spring6\3.1.3.RELEASE\4b276ea2bd536a18e44b40ff1d9f4848965ff59c\thymeleaf-spring6-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jdk8\2.19.4\90d304bcdb1a4bacb6f4347be625d75300973c60\jackson-datatype-jdk8-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.datatype\jackson-datatype-jsr310\2.19.4\3cbcf2e636a6b062772299bf19a347536e58c4df\jackson-datatype-jsr310-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.module\jackson-module-parameter-names\2.19.4\502dfea4c83502f444837b3d040a51e8475f15f2\jackson-module-parameter-names-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-databind\2.19.4\7a39bf9257b726b90b80f27fa3f5174bc75162a5\jackson-databind-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-autoconfigure\3.5.11\3c7d2ec2ac3c301e95814e37fed1c86c19927fc4\spring-boot-autoconfigure-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot\3.5.11\8b7f6df00bfbe74d370e1d05d985a127884d2a9c\spring-boot-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-starter-logging\3.5.11\62b692ed7aee31a5670796be8b07732b6b836f4e\spring-boot-starter-logging-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.annotation\jakarta.annotation-api\2.1.1\48b9bda22b091b1f48b13af03fe36db3be6e1ae3\jakarta.annotation-api-2.1.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-core\6.2.16\a73937f20a303e057add523915b48eb7901e1848\spring-core-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.yaml\snakeyaml\2.4\e0666b825b796f85521f02360e77f4c92c5a7a07\snakeyaml-2.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-websocket\10.1.52\9d32b801fb474306349013fcdd8317c8cb4d739e\tomcat-embed-websocket-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.tomcat.embed\tomcat-embed-core\10.1.52\f512bef2796b51299f4752f95918982c3003131d\tomcat-embed-core-10.1.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-context\6.2.16\caeae6bd50832d6ab28f707aa740e957401a5c20\spring-context-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-beans\6.2.16\990289064c810be71630fca9da8e2b6fe8f897b5\spring-beans-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-expression\6.2.16\e293ab797b1698084e56ae1f2362b315148683f6\spring-expression-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-observation\1.15.9\edf37b25cdfac0704d6fefa4543edb3ed1817eb0\micrometer-observation-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springdoc\springdoc-openapi-starter-common\2.8.16\5b702cb484981b42cfb455bd80b6ce7f49d34210\springdoc-openapi-starter-common-2.8.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.validation\jakarta.validation-api\3.0.2\92b6631659ba35ca09e44874d3eb936edfeee532\jakarta.validation-api-3.0.2.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.jboss.logging\jboss-logging\3.6.2.Final\3e0a139d7a74cc13b5e01daa8aaa7f71dccd577e\jboss-logging-3.6.2.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml\classmate\1.7.3\f61c7e7b81e9249b0f6a05914eff9d54fb09f4a0\classmate-1.7.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.zaxxer\HikariCP\6.3.3\7c5aec1e47a97ff40977e0193018865304ea9585\HikariCP-6.3.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jdbc\6.2.16\addfdde7b3212f34c95d791c37bb04ba4b08a1b7\spring-jdbc-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.persistence\jakarta.persistence-api\3.1.0\66901fa1c373c6aff65c13791cc11da72060a8d6\jakarta.persistence-api-3.1.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.transaction\jakarta.transaction-api\2.0.1\51a520e3fae406abb84e2e1148e6746ce3f80a1a\jakarta.transaction-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.data\spring-data-commons\3.5.9\6b577c71f563e78a7da984a3d572fde8a4df8103\spring-data-commons-3.5.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-orm\6.2.16\44b3cfb2c046440f83729641c929c405dc7f2c89\spring-orm-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-tx\6.2.16\5f9d6e78b76530e6258de8a0dff991fb1ad4b9b0\spring-tx-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.antlr\antlr4-runtime\4.13.0\5a02e48521624faaf5ff4d99afc88b01686af655\antlr4-runtime-4.13.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.aspectj\aspectjweaver\1.9.25.1\a713c790da4d794c7dfb542b550d4e44898d5e23\aspectjweaver-1.9.25.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-core\6.5.8\d052dca52e49d95d2b03f81ae4b6762eeb4c78d0\spring-security-core-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor\reactor-core\3.7.16\dc7f2ba3c4fbc69678937dfe1ad45264d8a1c7be\reactor-core-3.7.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-http\1.2.15\b20bb13c95b44f1d0c148bdf1197b7d4a7e0f278\reactor-netty-http-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-autoconfigure\3.3.0\b5a0b27e91ee997f8c86e4b0c521858fdcb9dc9b\spring-cloud-aws-autoconfigure-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.awspring.cloud\spring-cloud-aws-core\3.3.0\3c501426267d8ccbaaebc9796bd5de5bc5d0702e\spring-cloud-aws-core-3.3.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\s3\2.29.52\db65bc6177b0c4514be1f9775cb2094e29e85d3c\s3-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.thymeleaf\thymeleaf\3.1.3.RELEASE\51474f2a90b282ee97dabcd159c7faf24790f373\thymeleaf-3.1.3.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-core\2.19.4\a720ca9b800742699e041c3890f3731fe516085e\jackson-core-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.core\jackson-annotations\2.19.4\bbb09b1e7f7f5108890270eb701cb3ddef991c05\jackson-annotations-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-classic\1.5.32\2b1042c50f508f2eb402bd4d22ccbdf94cc37d2e\logback-classic-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-to-slf4j\2.24.3\da1143e2a2531ee1c2d90baa98eb50a28a39d5a7\log4j-to-slf4j-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.slf4j\jul-to-slf4j\2.0.17\524cb6ccc2b68a57604750e1ab8b13b5a786a6aa\jul-to-slf4j-2.0.17.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework\spring-jcl\6.2.16\8af6546d28815be574f384dceb93d248e9934f90\spring-jcl-6.2.16.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.micrometer\micrometer-commons\1.15.9\5a38f43cdc79a309a458c8ce130fff30a2a7f59\micrometer-commons-1.15.9.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-core-jakarta\2.2.43\500566364be54e3556bcec28922a41ca5fcc7dcd\swagger-core-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.security\spring-security-crypto\6.5.8\aec1a6f6c0e06be9dff08b11e8e1f457afca44b2\spring-security-crypto-6.5.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.reactivestreams\reactive-streams\1.0.4\3864a1320d97d7b045f729a326e1e077661f31b7\reactive-streams-1.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.projectreactor.netty\reactor-netty-core\1.2.15\94b2ca82f310c1bf31d3038060e4572eeca1d4b2\reactor-netty-core-1.2.15.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http2\4.1.131.Final\2e4c47131c60e0bbbca067c891597b466f7033ba\netty-codec-http2-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-http\4.1.131.Final\253d80637ed689ed309ca62371e5fb97746b165\netty-codec-http-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-native-macos\4.1.131.Final\9e4a908c073e56caa4127f54d946e3e9a5208506\netty-resolver-dns-native-macos-4.1.131.Final-osx-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns\4.1.131.Final\7714c0babe26712ccfdbc158aa64898ab909e7d8\netty-resolver-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-epoll\4.1.131.Final\10b7a905019c1ad5c37e8cf63d7229fb00668c1d\netty-transport-native-epoll-4.1.131.Final-linux-x86_64.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-core\2.29.52\dcfa86ac727b5d4e0abad1e8b025ac2febb6382e\aws-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\auth\2.29.52\76f9b22a99b0de0fd31447db22a5cba4ed4b172e\auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\regions\2.29.52\270b31c8695739d495452d380d036c72698e623\regions-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-xml-protocol\2.29.52\e7290d4528affec022bd2f3739853f774a955ac2\aws-xml-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\protocol-core\2.29.52\1a0e4a114c0943142ca395000a949ee840890fea\protocol-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\sdk-core\2.29.52\3f058b489fac3d091417339e02b165e72c637f61\sdk-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\arns\2.29.52\879712423589b58434b8831b9b75304a16983178\arns-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\profiles\2.29.52\59dd1368bff2d242d84515ec7ea8fe63bb472c4e\profiles-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\crt-core\2.29.52\6a16e04be0e8bb8a1767e0644c631dadddfdd764\crt-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth\2.29.52\aa4ce3ff7bcd8dcf131a4f5445455b3eb4926dcf\http-auth-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws\2.29.52\1d0dbfa072bc46207066ffa498ad4ed65c52ac6d\http-auth-aws-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-spi\2.29.52\4a64e68a88e3eef0b51819f742931f3607cdd996\http-auth-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\identity-spi\2.29.52\d18449651e8798398cadab6c4b5d8594bb0281c\identity-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums\2.29.52\90631313060ff8ef1ab7745bb1e9740913bdcefc\checksums-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries-spi\2.29.52\7e2c7ad44106799491de8cced5925b6473d62e4b\retries-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-client-spi\2.29.52\a8da4f289736c702ec6664836761412e7e1e54a2\http-client-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\metrics-spi\2.29.52\92c3797208d24b2b25ab9b6d1bbab624c3af1b9c\metrics-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\json-utils\2.29.52\d88c6c03061b9f3fcd17dc8456365dab67cc1597\json-utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\utils\2.29.52\bde94a15cd79b0240bfa10230970e2f0e4c51eba\utils-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\checksums-spi\2.29.52\537363296f035a935b7d3b50a5bef90014d38010\checksums-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\endpoints-spi\2.29.52\bd702a44ad440628af93afa1ec1d7cdc56baec67\endpoints-spi-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\annotations\2.29.52\9fa958ce528b57d90db01c5015daaf7bd373e57f\annotations-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.attoparser\attoparser\2.0.7.RELEASE\e5d0e988d9124139d645bb5872b24dfa23e283cc\attoparser-2.0.7.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.unbescape\unbescape\1.1.6.RELEASE\7b90360afb2b860e09e8347112800d12c12b2a13\unbescape-1.1.6.RELEASE.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\ch.qos.logback\logback-core\1.5.32\fdfb3ff9a842303d4a95207294a6c6bc64e2605d\logback-core-1.5.32.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.logging.log4j\log4j-api\2.24.3\b02c125db8b6d295adf72ae6e71af5d83bce2370\log4j-api-2.24.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-models-jakarta\2.2.43\a68f7470eb763609878460272000f260eabc24dc\swagger-models-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.fasterxml.jackson.dataformat\jackson-dataformat-yaml\2.19.4\500956daea0869bf753b94fdaa77e5dc99847d79\jackson-dataformat-yaml-2.19.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.apache.commons\commons-lang3\3.17.0\b17d2136f0460dcc0d2016ceefca8723bdf4ee70\commons-lang3-3.17.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.swagger.core.v3\swagger-annotations-jakarta\2.2.43\dbd40253251deabb7a628a54b4550dc4fb492f4\swagger-annotations-jakarta-2.2.43.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.xml.bind\jakarta.xml.bind-api\4.0.4\d6d2327f3817d9a33a3b6b8f2e15a96bc2e7afdc\jakarta.xml.bind-api-4.0.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler-proxy\4.1.131.Final\5ff9e74613a9dd3ca078f06880a16c8cdc046de0\netty-handler-proxy-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-handler\4.1.131.Final\5ca67999f41c0a68f0b66485ceb990683a0b0694\netty-handler-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec\4.1.131.Final\1874341f7b29879c6833c17e7305272f0cdc2cb6\netty-codec-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport\4.1.131.Final\474862e0855d7a9828fab06a9c73c05387604ee3\netty-transport-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-buffer\4.1.131.Final\f97b636ecd9b81ae3fd1d039b69c4fd3959ecf\netty-buffer-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-common\4.1.131.Final\cdc659109da226b698a74b543a5b97dd0f7e6959\netty-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver-dns-classes-macos\4.1.131.Final\b9d57038cc4144e36aee5898085b7f1f018d2c9f\netty-resolver-dns-classes-macos-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-dns\4.1.131.Final\cd23e12e5c3448a1b12c8a4b8deeb4faeb5e483e\netty-codec-dns-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-resolver\4.1.131.Final\9db1bfd7c57b9b6aa9b5cfc61fc3304594bb6b39\netty-resolver-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-classes-epoll\4.1.131.Final\4d7848ac709491fb14f8bce2796fc3eff4a04fd6\netty-transport-classes-epoll-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-transport-native-unix-common\4.1.131.Final\fa975e4751b23d50c0a60569829f31944d11d292\netty-transport-native-unix-common-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\retries\2.29.52\48cb57817dd88977ec71e63550673c5ce010a191\retries-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.eventstream\eventstream\1.0.1\6ff8649dffc5190366ada897ba8525a836297784\eventstream-1.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\http-auth-aws-eventstream\2.29.52\e8b723c48008bcac96e2cc34c7415bd8b581c601\http-auth-aws-eventstream-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\aws-query-protocol\2.29.52\672d7a2df481414d02eedf3a9eff45fb87f1b8a\aws-query-protocol-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\third-party-jackson-core\2.29.52\82ff600d837e83130502775a1555c45d7a3e2e1\third-party-jackson-core-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.activation\jakarta.activation-api\2.1.4\9e5c2a0d75dde71a0bedc4dbdbe47b78a5dc50f8\jakarta.activation-api-2.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.netty\netty-codec-socks\4.1.131.Final\614eacc17f44d8abaeaea81f210b4980c3568262\netty-codec-socks-4.1.131.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.springframework.boot\spring-boot-devtools\3.5.11\fe7dfcaf3153d049909a618a7ba7df288e80f090\spring-boot-devtools-3.5.11.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-impl\0.12.6\ac23673a84b6089e0369fb8ab2c69edd91cd6eb0\jjwt-impl-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.jsonwebtoken\jjwt-jackson\0.12.6\f141e0c1136ba17f2632858238a31ae05642dbf8\jjwt-jackson-0.12.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.postgresql\postgresql\42.7.10\35100a3f0899551e27af8fed4a3414619a4663b3\postgresql-42.7.10.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.h2database\h2\2.3.232\4fcc05d966ccdb2812ae8b9a718f69226c0cf4e2\h2-2.3.232.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-protobuf-lite\1.69.0\91711f27421babf868e424a64426fccb9e8bf6ec\grpc-protobuf-lite-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.checkerframework\checker-qual\3.52.0\9c17f496846ab1fca8975c6a50ceac0b3bbe63f0\checker-qual-3.52.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.android\annotations\4.1.1.4\a1678ba907bf92691d879fef34e1a187038f9259\annotations-4.1.1.4.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.codehaus.mojo\animal-sniffer-annotations\1.24\aa9ba58d30e0aad7f1808fce9c541ea3760678d8\animal-sniffer-annotations-1.24.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-util\1.69.0\1929ab12fce0c610d9d7229b8767f6abb09ebd51\grpc-util-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.perfmark\perfmark-api\0.27.0\f86f575a41b091786a4b027cd9c0c1d2e3fc1c01\perfmark-api-0.27.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-googleapis\1.69.0\1c11a033d96689a4bf3c92fe89b35f5edaef10c2\grpc-googleapis-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-xds\1.69.0\fa1d282a8ba3ae2a5dc0205d2f6a18b5606b5b62\grpc-xds-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.grpc\grpc-services\1.69.0\bcd917dad2380ee7cf4728da33816ffb9fad6b8b\grpc-services-1.69.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.google.re2j\re2j\1.7\2949632c1b4acce0d7784f28e3152e9cf3c2ec7a\re2j-1.7.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.hibernate.common\hibernate-commons-annotations\7.0.3.Final\e183c4be8bb41d12e9f19b374e00c34a0a85f439\hibernate-commons-annotations-7.0.3.Final.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\io.smallrye\jandex\3.2.0\f17ad860f62a08487b9edabde608f8ac55c62fa7\jandex-3.2.0.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\net.bytebuddy\byte-buddy\1.17.8\af5735f63d00ca47a9375fae5c7471a36331c6ed\byte-buddy-1.17.8.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-runtime\4.0.6\fb95ebb62564657b2fedfe165b859789ef3a8711\jaxb-runtime-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\jakarta.inject\jakarta.inject-api\2.0.1\4c28afe1991a941d7702fe1362c365f0a8641d1e\jakarta.inject-api-2.0.1.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\jaxb-core\4.0.6\8e61282303777fc98a00cc3affd0560d68748a75\jaxb-core-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\apache-client\2.29.52\b7ce213c946d69ab1807b3f7ecac1ce29ed60485\apache-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\software.amazon.awssdk\netty-nio-client\2.29.52\20fa79ba82d3b290b12cd10ca49f0ff7608a6107\netty-nio-client-2.29.52.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.eclipse.angus\angus-activation\2.0.3\7f80607ea5014fef0b1779e6c33d63a88a45a563\angus-activation-2.0.3.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\org.glassfish.jaxb\txw2\4.0.6\4f4cd53b5ff9a2c5aa1211f15ed2569c57dfb044\txw2-4.0.6.jar;C:\Users\guswn\.gradle\caches\modules-2\files-2.1\com.sun.istack\istack-commons-runtime\4.1.2\18ec117c85f3ba0ac65409136afa8e42bc74e739\istack-commons-runtime-4.1.2.jar -Launcher Type: SUN_STANDARD - -[Global flags] - intx CICompilerCount = 12 {product} {ergonomic} - uint ConcGCThreads = 4 {product} {ergonomic} - uint G1ConcRefinementThreads = 14 {product} {ergonomic} - size_t G1HeapRegionSize = 4194304 {product} {ergonomic} - uintx GCDrainStackTargetSize = 64 {product} {ergonomic} - size_t InitialHeapSize = 532676608 {product} {ergonomic} - bool ManagementServer = true {product} {command line} - size_t MarkStackSize = 4194304 {product} {ergonomic} - size_t MaxHeapSize = 8480882688 {product} {ergonomic} - size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic} - size_t MinHeapSize = 8388608 {product} {ergonomic} - uintx NonNMethodCodeHeapSize = 4096 {pd product} {ergonomic} - uintx NonProfiledCodeHeapSize = 0 {pd product} {ergonomic} - bool ProfileInterpreter = false {pd product} {command line} - uintx ProfiledCodeHeapSize = 0 {pd product} {ergonomic} - size_t SoftMaxHeapSize = 8480882688 {manageable} {ergonomic} - intx TieredStopAtLevel = 1 {product} {command line} - bool UseCompressedOops = true {product lp64_product} {ergonomic} - bool UseG1GC = true {product} {ergonomic} - bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic} - -Logging: -Log output configuration: - #0: stdout all=warning uptime,level,tags foldmultilines=false - #1: stderr all=off uptime,level,tags foldmultilines=false - -Release file: - -Environment Variables: -JAVA_HOME=C:\Users\guswn\.jdks\corretto-19.0.2 -CLASSPATH=%JAVA_HOME%\lib -PATH=%JAVA_HOME%\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;D:\application\Git\cmd;C:\Program Files\Docker\Docker\resources\bin;D:\application\putty\;C:\Program Files\dotnet\;C:\Program Files\Bandizip\;D:\application\nodejs\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\Scripts\;C:\Users\guswn\AppData\Local\Programs\Python\Python312\;C:\Users\guswn\AppData\Local\Programs\Python\Launcher\;C:\Users\guswn\AppData\Local\Microsoft\WindowsApps;D:\application\Microsoft VS Code\bin;C:\Users\guswn\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\guswn\.local\bin;D:\application\IntelliJ IDEA 2025.3.2\bin;D:\application\JetBrains Gateway 2025.3.2\bin;C:\Users\guswn\AppData\Roaming\npm -USERNAME=guswn -OS=Windows_NT -PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 170 Stepping 4, GenuineIntel -TMP=C:\Users\guswn\AppData\Local\Temp -TEMP=C:\Users\guswn\AppData\Local\Temp - - - - -Periodic native trim disabled - ---------------- S Y S T E M --------------- - -OS: - Windows 11 , 64 bit Build 26100 (10.0.26100.7920) -OS uptime: 16 days 18:33 hours -Hyper-V role detected - -CPU: total 18 (initial active 18) (9 cores per cpu, 2 threads per core) family 6 model 170 stepping 4 microcode 0x1f, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, sha, fma, vzeroupper, clflush, clflushopt, clwb, hv, serialize, rdtscp, rdpid, fsrm, f16c, cet_ibt, cet_ss -Processor Information for processor 0 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 1 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 2 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 3 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 4 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 5 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 6 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 7 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 8 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 9 - Max Mhz: 3600, Current Mhz: 700, Mhz Limit: 684 -Processor Information for processor 10 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 11 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 12 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 13 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 14 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 15 - Max Mhz: 1200, Current Mhz: 1200, Mhz Limit: 1200 -Processor Information for processor 16 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 -Processor Information for processor 17 - Max Mhz: 2500, Current Mhz: 700, Mhz Limit: 700 - -Memory: 4k page, system-wide physical 32346M (3866M free) -TotalPageFile size 61970M (AvailPageFile size 362M) -current process WorkingSet (physical memory assigned to process): 13M, peak: 13M -current process commit charge ("private bytes"): 68M, peak: 576M - -vm_info: OpenJDK 64-Bit Server VM (21.0.10+7-LTS) for windows-amd64 JRE (21.0.10+7-LTS), built on 2026-01-15T22:13:46Z by "Administrator" with MS VC++ 17.14 (VS2022) - -END. diff --git a/railpack.json b/railpack.json new file mode 100644 index 00000000..a875b64b --- /dev/null +++ b/railpack.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://schema.railpack.com", + "buildAptPackages": ["ffmpeg"], + "deploy": { + "aptPackages": ["ffmpeg"] + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java new file mode 100644 index 00000000..7aba2de7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignClient.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.client; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 애드픽 캠페인 리스트 API 호출. + * 애드픽 가이드가 최대 1분에 1회 이하 호출을 요구하므로 요청마다 부르지 않고 스케줄러로만 부른다. + * 실제로 짧은 간격으로 연달아 호출하면 403 을 돌려준다. + */ +@Slf4j +@Component +public class AdpickCampaignClient { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Value("${picke.ad.adpick.base-url:https://adpick.co.kr/apis/offers.php}") + private String baseUrl; + + @Value("${picke.ad.adpick.aff-id:}") + private String affId; + + /** 가입 전에는 affId 가 비어 있다. 그때는 동기화를 건너뛴다. */ + public boolean isConfigured() { + return StringUtils.hasText(affId); + } + + public List fetchCampaigns() { + String url = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("affid", affId) + .queryParam("order", "rand") + .build() + .toUriString(); + + List campaigns = WebClient.create() + .get() + .uri(url) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .timeout(TIMEOUT) + .block(); + + return campaigns == null ? List.of() : campaigns; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java new file mode 100644 index 00000000..84a612e4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponse.java @@ -0,0 +1,42 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * 애드픽 캠페인 리스트 API(offers.php) 응답 한 건. + * 문서화되지 않은 필드가 늘어날 수 있어 모르는 필드는 무시한다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AdpickCampaignResponse( + + @JsonProperty("apOffer") String offerId, + @JsonProperty("apAppTitle") String appTitle, + @JsonProperty("apHeadline") String headline, + @JsonProperty("apAppPromoText") String promoText, + @JsonProperty("apImages") Map images, + @JsonProperty("apTrackingLink") String trackingLink, + @JsonProperty("apOS") String os, + @JsonProperty("apRemain") Integer remain +) { + + private static final String ICON_KEY = "icon"; + + public String iconUrl() { + return images == null ? null : images.get(ICON_KEY); + } + + /** 잔여 수량이 없는 캠페인은 클릭해도 전환이 잡히지 않으므로 노출하지 않는다. */ + public boolean hasRemaining() { + return remain != null && remain > 0; + } + + /** 이미지나 추적 링크가 비면 배너를 그릴 수 없다. */ + public boolean isRenderable() { + return offerId != null && !offerId.isBlank() + && appTitle != null && !appTitle.isBlank() + && trackingLink != null && !trackingLink.isBlank() + && iconUrl() != null; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java new file mode 100644 index 00000000..0551b45a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingClient.java @@ -0,0 +1,92 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 애드픽 쇼핑·핫딜 상품을 가져온다. + * + *

캠페인 리스트 API(offers.php)와 별도 재고다. 이 계정 기준으로 앱 캠페인은 7건뿐이지만 + * 쇼핑과 핫딜에서 각각 30건이 더 내려온다. 지면을 채우려면 이쪽이 필요하다. + * + *

두 API는 응답이 {@code {title, description, list:[...]}} 로 한 겹 싸여 있다. + * 형태가 같아 같은 파서를 쓴다. + */ +@Slf4j +@Component +public class AdpickShoppingClient { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final String shoppingUrl; + private final String hotdealUrl; + private final String affId; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public AdpickShoppingClient( + @Value("${picke.ad.adpick.shopping-url:https://adpick.co.kr/apis/sdk_shopping.php}") String shoppingUrl, + @Value("${picke.ad.adpick.hotdeal-url:https://adpick.co.kr/apis/sdk_shopping_hotdeal.php}") String hotdealUrl, + @Value("${picke.ad.adpick.aff-id:}") String affId) { + this.shoppingUrl = shoppingUrl; + this.hotdealUrl = hotdealUrl; + this.affId = affId; + } + + public boolean isConfigured() { + return StringUtils.hasText(affId); + } + + /** 쇼핑과 핫딜을 합쳐 준다. 한쪽이 실패해도 다른 쪽은 살린다. */ + public List fetchProducts() { + List products = new ArrayList<>(); + products.addAll(fetchFrom(shoppingUrl)); + products.addAll(fetchFrom(hotdealUrl)); + return products; + } + + private List fetchFrom(String baseUrl) { + String url = UriComponentsBuilder.fromUriString(baseUrl) + .queryParam("affid", affId) + .build() + .toUriString(); + try { + // 이 API 는 JSON 을 text/html 로 내려준다. 컨텐츠 타입만 믿고 디코딩하면 전부 실패한다. + String body = WebClient.create() + .get() + .uri(url) + .retrieve() + .bodyToMono(String.class) + .timeout(TIMEOUT) + .block(); + + if (body == null || body.isBlank()) { + return List.of(); + } + List groups = objectMapper.readValue(body, new TypeReference>() { + }); + return groups.stream() + .filter(group -> group.list() != null) + .flatMap(group -> group.list().stream()) + .toList(); + } catch (Exception e) { + // 재고를 늘리려고 붙인 부가 피드다. 여기서 터져도 앱 캠페인 동기화까지 막지 않는다. + log.warn("[AdpickShopping] {} 조회 실패: {}", baseUrl, e.getMessage()); + return List.of(); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record Group(@JsonProperty("list") List list) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java new file mode 100644 index 00000000..b3c95dcf --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/client/AdpickShoppingResponse.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 애드픽 쇼핑·핫딜 API(sdk_shopping.php, sdk_shopping_hotdeal.php)의 상품 한 건. + * + *

앱 설치형 캠페인과 달리 상품에는 애드픽이 주는 고유 코드가 없다. + * {@code buyurl} 이 이미 affid 가 박힌 완성된 추적 링크라, 이 값을 식별자의 근거로 삼는다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AdpickShoppingResponse( + + @JsonProperty("product_name") String productName, + @JsonProperty("photo") String photo, + @JsonProperty("mall_name") String mallName, + @JsonProperty("commission") String commission, + @JsonProperty("buyurl") String buyUrl +) { + + /** 이미지나 구매 링크가 비면 배너를 그릴 수 없다. */ + public boolean isRenderable() { + return productName != null && !productName.isBlank() + && photo != null && !photo.isBlank() + && buyUrl != null && !buyUrl.isBlank(); + } + + /** 판매처와 수수료를 보조 문구로 쓴다. 둘 다 없으면 보조 문구를 비운다. */ + public String subtitle() { + if (mallName == null || mallName.isBlank()) { + return commission == null || commission.isBlank() ? null : commission; + } + return commission == null || commission.isBlank() + ? mallName + : mallName + " · " + commission; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java new file mode 100644 index 00000000..12ac991e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdClickController.java @@ -0,0 +1,51 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdClickService; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.servlet.view.RedirectView; + +/** + * 제휴 링크 클릭 진입점. + * /api/v1 아래에 두지 않는다. 외부 브라우저가 여는 공개 숏링크라 짧아야 하고, JSON API가 아니다. + */ +@Controller +@RequiredArgsConstructor +public class AdClickController { + + private static final String FORWARDED_FOR = "X-Forwarded-For"; + + private final AdClickService adClickService; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + @GetMapping("/c/{code}") + public RedirectView click(@PathVariable String code, HttpServletRequest request) { + Optional target = adClickService.resolveTarget(code); + + if (target.isEmpty()) { + // 만료되었거나 없는 코드다. 404를 보여주는 대신 랜딩으로 흘려보낸다. + return new RedirectView(adBaseUrl + "/"); + } + + AdClickTarget clickTarget = target.get(); + adClickService.recordClick(clickTarget, resolveClientIp(request), request.getHeader("User-Agent")); + + return new RedirectView(clickTarget.redirectUrl()); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader(FORWARDED_FOR); + if (forwardedFor == null || forwardedFor.isBlank()) { + return request.getRemoteAddr(); + } + return forwardedFor.split(",")[0].trim(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java new file mode 100644 index 00000000..3b0f4140 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdController.java @@ -0,0 +1,52 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.dto.request.AdImpressionRequest; +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.service.AdQueryService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "제휴 광고 API", description = "앱 지면에 노출할 제휴 광고 조회 및 노출 집계") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/ads") +public class AdController { + + private final AdQueryService adQueryService; + + @Operation(summary = "지면별 광고 조회", + description = "게재 가능한 소재가 없으면 빈 배열을 준다. 앱은 이때 지면 자체를 숨긴다.") + @GetMapping + public ApiResponse> getAds( + @Parameter(description = "노출 지면", example = "HOME_FEED") + @RequestParam AdSlotCode slot, + @Parameter(description = "요청 OS. 앱 설치형 캠페인이 OS별로 갈리므로 실제 OS를 보내야 한다.", + example = "ANDROID") + @RequestParam(defaultValue = "ALL") AdTargetOs os, + @Parameter(description = "받아갈 소재 개수", example = "1") + @RequestParam(defaultValue = "1") int size + ) { + return ApiResponse.onSuccess(adQueryService.findServableAds(slot, os, size)); + } + + @Operation(summary = "광고 노출 집계", + description = "조회가 아니라 실제로 화면에 그려진 시점에 호출한다. 조회를 노출로 세면 CTR이 왜곡된다.") + @PostMapping("/impressions") + public ApiResponse recordImpressions(@Valid @RequestBody AdImpressionRequest request) { + adQueryService.recordImpressions(request.codes()); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java new file mode 100644 index 00000000..461fbfb3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/controller/AdLandingController.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.service.AdQueryService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * ad.picke.store 루트 공개 지면. + * 쿠팡 파트너스 매체 심사에서 URL 접속 확인을 하므로 실제 콘텐츠가 있어야 한다. + * 광고 도메인이 아닌 Host로 들어오면 최소 응답만 준다. API 도메인 루트에 광고 페이지가 뜨면 안 된다. + */ +@Controller +@RequiredArgsConstructor +public class AdLandingController { + + private final AdQueryService adQueryService; + + @Value("${picke.ad.host:ad.picke.store}") + private String adHost; + + @GetMapping("/") + public Object landing(HttpServletRequest request, Model model) { + if (!adHost.equalsIgnoreCase(request.getServerName())) { + return ResponseEntity.ok("PICKE"); + } + + model.addAttribute("ads", adQueryService.findLandingAds()); + return "ad/landing"; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java new file mode 100644 index 00000000..3590aa42 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/request/AdImpressionRequest.java @@ -0,0 +1,22 @@ +package com.swyp.picke.domain.ad.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 노출 집계는 익명 공개 API다. 아무나 반복 호출해 노출수를 부풀릴 수 있다는 것을 감수하는 대신, + * 한 번에 밀어 넣을 수 있는 양은 막아 둔다. 한 화면에 그려지는 소재 수가 이보다 많을 일은 없다. + */ +@Schema(description = "광고 노출 집계 요청") +public record AdImpressionRequest( + + @Schema(description = "실제로 화면에 노출된 소재 코드 목록", example = "[\"a1b2c3d4\"]") + @NotEmpty(message = "노출된 소재 코드는 최소 1개 이상이어야 합니다.") + @Size(max = MAX_CODES, message = "한 번에 집계할 수 있는 소재는 최대 20개입니다.") + List codes +) { + + private static final int MAX_CODES = 20; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java new file mode 100644 index 00000000..a65e8b8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/dto/response/AdResponse.java @@ -0,0 +1,50 @@ +package com.swyp.picke.domain.ad.dto.response; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "앱 지면에 노출할 제휴 광고 소재") +public record AdResponse( + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "매체", example = "COUPANG") + AdNetwork network, + + @Schema(description = "배너 주 문구", example = "지금 인기 있는 무선 이어폰") + String title, + + @Schema(description = "배너 보조 문구", example = "리뷰 1만 개 이상") + String subtitle, + + @Schema(description = "소재 이미지 URL") + String imageUrl, + + @Schema(description = "버튼 문구", example = "구매하러 가기") + String ctaText, + + @Schema(description = "탭 시 이동할 URL. 외부 브라우저로 열어야 한다.", + example = "https://ad.picke.store/c/a1b2c3d4") + String clickUrl, + + @Schema(description = "광고 표기 라벨. 표시광고법 대응이므로 반드시 렌더해야 한다.", example = "광고") + String label +) { + + private static final String AD_LABEL = "광고"; + + public static AdResponse of(AdCreative creative, String clickUrl) { + return new AdResponse( + creative.getCode(), + creative.getNetwork(), + creative.getTitle(), + creative.getSubtitle(), + creative.getImageUrl(), + creative.getCtaText(), + clickUrl, + AD_LABEL + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java new file mode 100644 index 00000000..d791cbc9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdClickLog.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import org.hibernate.annotations.Check; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 클릭 원장. 제휴사 리포트와 대조하는 용도다. + * 클릭 시각은 {@code BaseEntity.createdAt}이다. /c/{code}는 외부 브라우저에서 열려 + * Authorization 헤더가 없으므로 사용자를 특정하지 않는다. + */ +@Entity +@Getter +@Table(name = "ad_click_logs", indexes = { + @Index(name = "idx_ad_click_logs_creative", columnList = "creative_id") +}) +@Check(name = "ck_ad_click_logs_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdClickLog extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + /** 원본 IP는 저장하지 않는다. 중복 클릭 판별에 필요한 정도만 남긴다. */ + @Column(name = "ip_hash", length = 64) + private String ipHash; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @Builder + private AdClickLog(Long creativeId, AdSlotCode slot, String ipHash, String userAgent) { + this.creativeId = creativeId; + this.slot = slot; + this.ipHash = ipHash; + this.userAgent = userAgent; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java new file mode 100644 index 00000000..2eb1a5d0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdCreative.java @@ -0,0 +1,170 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.Check; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 제휴 광고 소재. 각 매체 콘솔에서 발급한 완성형 제휴 링크를 어드민이 등록한다. + */ +@Entity +@Getter +@Table(name = "ad_creatives", uniqueConstraints = { + @UniqueConstraint(name = "uk_ad_creatives_external", columnNames = {"source", "external_id"}) +}) +@Check(name = "ck_ad_creatives_network", constraints = "network in ('COUPANG', 'ADPICK')") +@Check(name = "ck_ad_creatives_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@Check(name = "ck_ad_creatives_status", constraints = "status in ('DRAFT', 'ACTIVE', 'PAUSED')") +@Check(name = "ck_ad_creatives_source", constraints = "source in ('MANUAL', 'ADPICK_API')") +@Check(name = "ck_ad_creatives_target_os", constraints = "target_os in ('ALL', 'ANDROID', 'IOS')") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdCreative extends BaseEntity { + + /** 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드. PK를 그대로 드러내지 않기 위해 둔다. */ + @Column(name = "code", nullable = false, unique = true, length = 16) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "network", nullable = false, length = 20) + private AdNetwork network; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "subtitle", length = 200) + private String subtitle; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + /** 쿠팡은 "구매하러 가기", 애드픽 CPI는 "설치하고 받기" 식으로 성격이 달라 소재 단위로 둔다. */ + @Column(name = "cta_text", nullable = false, length = 30) + private String ctaText; + + @Column(name = "landing_url", nullable = false, length = 1000) + private String landingUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private AdStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 20) + private AdSource source; + + /** 매체 쪽 캠페인 식별자. 동기화 소재를 다시 찾을 때 쓴다. 수동 등록 소재는 비어 있다. */ + @Column(name = "external_id", length = 64) + private String externalId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_os", nullable = false, length = 20) + private AdTargetOs targetOs; + + @Column(name = "weight", nullable = false) + private int weight; + + @Column(name = "starts_at") + private LocalDateTime startsAt; + + @Column(name = "ends_at") + private LocalDateTime endsAt; + + @Builder + private AdCreative(String code, AdNetwork network, AdSlotCode slot, String title, String subtitle, + String imageUrl, String ctaText, String landingUrl, AdStatus status, Integer weight, + LocalDateTime startsAt, LocalDateTime endsAt, AdSource source, String externalId, + AdTargetOs targetOs) { + this.code = code; + this.network = network; + this.slot = slot; + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.status = status != null ? status : AdStatus.DRAFT; + this.weight = weight != null ? weight : 1; + this.startsAt = startsAt; + this.endsAt = endsAt; + this.source = source != null ? source : AdSource.MANUAL; + this.externalId = externalId; + this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL; + } + + public void update(AdNetwork network, AdSlotCode slot, String title, String subtitle, String imageUrl, + String ctaText, String landingUrl, AdStatus status, Integer weight, + LocalDateTime startsAt, LocalDateTime endsAt, AdTargetOs targetOs) { + this.network = network; + this.slot = slot; + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.status = status; + this.weight = weight != null ? weight : 1; + this.startsAt = startsAt; + this.endsAt = endsAt; + this.targetOs = targetOs != null ? targetOs : AdTargetOs.ALL; + } + + /** + * 애드픽 동기화가 매 회차 내용을 덮어쓴다. + * 어드민이 꺼둔 소재는 다시 켜지 않는다. 별도 플래그 없이 PAUSED 를 끄기 스위치로 쓴다. + */ + public void syncFromAdpick(String title, String subtitle, String imageUrl, String ctaText, + String landingUrl, AdTargetOs targetOs, AdSlotCode slot, int weight, + boolean servable) { + this.title = title; + this.subtitle = subtitle; + this.imageUrl = imageUrl; + this.ctaText = ctaText; + this.landingUrl = landingUrl; + this.targetOs = targetOs; + this.slot = slot; + this.weight = weight; + if (this.status != AdStatus.PAUSED) { + this.status = servable ? AdStatus.ACTIVE : AdStatus.DRAFT; + } + } + + /** 동기화가 소유하는 소재는 어드민이 내용을 고치거나 지우지 않는다. */ + public boolean isManaged() { + return source == AdSource.ADPICK_API; + } + + public void changeStatus(AdStatus status) { + this.status = status; + } + + /** 게재 가능 여부. status와 기간을 함께 본다. */ + public boolean isServable(LocalDateTime now) { + if (status != AdStatus.ACTIVE) { + return false; + } + if (startsAt != null && now.isBefore(startsAt)) { + return false; + } + return endsAt == null || !now.isAfter(endsAt); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java new file mode 100644 index 00000000..7177c861 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/entity/AdImpressionDaily.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.Check; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 일별 노출 집계. + * 노출을 raw 로그로 쌓으면 배너가 스크롤에 걸릴 때마다 행이 생겨 금방 수천만 건이 된다. + * CTR 산출에는 일별 카운터로 충분하다. + */ +@Entity +@Getter +@Table(name = "ad_impression_daily", uniqueConstraints = { + @UniqueConstraint(name = "uk_ad_impression_daily", columnNames = {"creative_id", "slot", "stat_date"}) +}) +@Check(name = "ck_ad_impression_daily_slot", constraints = "slot in ('HOME_FEED', 'BATTLE_RESULT_BOTTOM', " + + "'CHAT_ROOM_INLINE', 'ATTENDANCE_COMPLETE', 'PROFILE_BOTTOM')") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdImpressionDaily extends BaseEntity { + + @Column(name = "creative_id", nullable = false) + private Long creativeId; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 40) + private AdSlotCode slot; + + @Column(name = "stat_date", nullable = false) + private LocalDate statDate; + + @Column(name = "impressions", nullable = false) + private long impressions; + + @Builder + private AdImpressionDaily(Long creativeId, AdSlotCode slot, LocalDate statDate, long impressions) { + this.creativeId = creativeId; + this.slot = slot; + this.statDate = statDate; + this.impressions = impressions; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java new file mode 100644 index 00000000..d22d4bf5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdNetwork.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 제휴 광고 매체. AdMob 리워드 광고(reward 도메인)와는 무관하다. + */ +@Getter +@RequiredArgsConstructor +public enum AdNetwork { + + COUPANG("쿠팡 파트너스"), + ADPICK("애드픽"); + + private final String description; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java new file mode 100644 index 00000000..1799e776 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSlotCode.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 광고 노출 지면. + * 테이블이 아니라 enum인 이유는, 앱이 그릴 줄 모르는 지면을 어드민에서 만들어봐야 + * 아무 일도 일어나지 않기 때문이다. 지면 추가는 어차피 앱 배포와 묶인다. + * 목록은 iOS Presentation 모듈(Home/Battle/Chat/Profile)의 실제 화면을 기준으로 잡았다. + * 앱팀 확정 전이므로 실제로 붙이는 지면만 소재를 등록하면 된다. + * 소재가 없는 지면은 빈 배열을 반환하고 앱은 지면 자체를 숨긴다. + * {@code cpiFriendly}는 앱 설치형(CPI) 광고를 놓아도 되는 지면인지를 뜻한다. + * CPI는 단가가 높지만 클릭하면 사용자가 스토어로 나가 다른 앱을 설치한다. + * 세션이 자연스럽게 끝나는 지점이 아니면 이탈·리텐션에 그대로 타격이 온다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSlotCode { + + HOME_FEED("홈 피드 인라인", false), + BATTLE_RESULT_BOTTOM("배틀 결과 하단", true), + CHAT_ROOM_INLINE("관점 목록 인라인", false), + ATTENDANCE_COMPLETE("출석 완료 후", true), + PROFILE_BOTTOM("프로필 하단", true); + + private final String description; + + /** 앱 설치형(CPI) 광고를 놓아도 되는 지면인지. */ + private final boolean cpiFriendly; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java new file mode 100644 index 00000000..ca796e09 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdSource.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 소재를 누가 만들었는지. + * ADPICK_API 소재는 동기화가 내용을 덮어쓰므로 어드민에서 수정·삭제하지 않는다. + */ +@Getter +@RequiredArgsConstructor +public enum AdSource { + + MANUAL("어드민 수동 등록"), + ADPICK_API("애드픽 캠페인 API 동기화"); + + private final String description; +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java new file mode 100644 index 00000000..42ccbb24 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdStatus.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.ad.enums; + +public enum AdStatus { + DRAFT, + ACTIVE, + PAUSED +} diff --git a/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java new file mode 100644 index 00000000..52e82bc1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/enums/AdTargetOs.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.ad.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 소재를 노출할 OS. + * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, iOS 사용자에게 Android 캠페인을 보여주면 + * 클릭해도 전환이 일어나지 않는다. + */ +@Getter +@RequiredArgsConstructor +public enum AdTargetOs { + + ALL("전체"), + ANDROID("Android"), + IOS("iOS"); + + private final String description; + + /** 애드픽 apOS 값을 우리 enum으로 옮긴다. 모르는 값은 노출하지 않도록 비운다. */ + public static AdTargetOs fromAdpick(String apOs) { + if (apOs == null || apOs.isBlank()) { + return ALL; + } + String normalized = apOs.trim().toLowerCase(); + if (normalized.startsWith("and")) { + return ANDROID; + } + if (normalized.startsWith("ios") || normalized.startsWith("iphone")) { + return IOS; + } + return ALL; + } + + /** 요청 OS에 이 타깃을 노출해도 되는지. */ + public boolean matches(AdTargetOs requested) { + return this == ALL || requested == ALL || this == requested; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java new file mode 100644 index 00000000..97464760 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilder.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 애드픽 서브아이디 파라미터명은 설정값으로 둔다. + * 파트너센터 링크생성 화면에서 규격을 확인하기 전까지는 값이 비어 있고, 그동안은 pass-through로 + * 원본 링크를 그대로 넘긴다. 지면별 성과 분리만 안 될 뿐 노출·클릭·리다이렉트는 정상 동작한다. + * 규격이 확인되면 배포 없이 {@code picke.ad.adpick.sub-id-param} 만 채우면 쿠팡과 같은 방식으로 붙는다. + */ +@Component +public class AdpickLinkBuilder implements AffiliateLinkBuilder { + + @Value("${picke.ad.adpick.sub-id-param:}") + private String subIdParam; + + @Override + public AdNetwork network() { + return AdNetwork.ADPICK; + } + + @Override + public String build(AdCreative creative) { + if (!StringUtils.hasText(subIdParam)) { + return creative.getLandingUrl(); + } + return AffiliateLinks.merge(creative.getLandingUrl(), subIdParam, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java new file mode 100644 index 00000000..89091aac --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkBuilder.java @@ -0,0 +1,16 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; + +/** + * 매체별 최종 이동 URL을 만든다. + * 소재의 landingUrl은 각 매체 콘솔에서 발급한 완성형 링크라 이미 쿼리스트링을 갖고 있다. + * 추적 파라미터는 문자열 결합이 아니라 병합이어야 한다. + */ +public interface AffiliateLinkBuilder { + + AdNetwork network(); + + String build(AdCreative creative); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java new file mode 100644 index 00000000..b3f9be88 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinkResolver.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +public class AffiliateLinkResolver { + + private final Map builders = new EnumMap<>(AdNetwork.class); + + public AffiliateLinkResolver(List builderList) { + builderList.forEach(builder -> builders.put(builder.network(), builder)); + } + + public String resolve(AdCreative creative) { + AffiliateLinkBuilder builder = builders.get(creative.getNetwork()); + if (builder == null) { + return creative.getLandingUrl(); + } + return builder.build(creative); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java new file mode 100644 index 00000000..335d0198 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/AffiliateLinks.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.ad.link; + +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 제휴 링크에 추적 파라미터를 끼워 넣는 공통 규칙. + */ +final class AffiliateLinks { + + private AffiliateLinks() { + } + + /** + * 이미 쿼리스트링이 붙어 있는 제휴 링크에 파라미터를 병합한다. + * 단순 문자열 결합이 아니다. 같은 이름의 파라미터가 이미 있으면 우리 값으로 덮는다. + * build(true)로 두는 이유는 landingUrl이 각 매체 콘솔에서 인코딩까지 끝난 상태로 오기 때문이다. + * 여기서 다시 인코딩하면 이중 인코딩된다. + */ + static String merge(String landingUrl, String paramName, String value) { + return UriComponentsBuilder.fromUriString(landingUrl) + .replaceQueryParam(paramName, value) + .build(true) + .toUriString(); + } + + /** 지면별 성과를 가르기 위한 추적값. 영문·숫자·밑줄만 쓰므로 인코딩이 필요 없다. */ + static String subIdOf(com.swyp.picke.domain.ad.entity.AdCreative creative) { + return creative.getSlot().name() + "_" + creative.getCode(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java new file mode 100644 index 00000000..601796b3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilder.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import org.springframework.stereotype.Component; + +/** + * 쿠팡 파트너스는 subId를 지원한다. 지면별로 값을 달리 넣으면 파트너스 리포트에서 + * 지면별 실매출이 갈려, 어느 지면이 돈이 되는지 데이터로 볼 수 있다. + */ +@Component +public class CoupangLinkBuilder implements AffiliateLinkBuilder { + + private static final String SUB_ID_PARAM = "subId"; + + @Override + public AdNetwork network() { + return AdNetwork.COUPANG; + } + + @Override + public String build(AdCreative creative) { + return AffiliateLinks.merge(creative.getLandingUrl(), SUB_ID_PARAM, AffiliateLinks.subIdOf(creative)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java new file mode 100644 index 00000000..76c025a0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdClickLogRepository.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import java.time.LocalDateTime; +import java.util.List; +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; + +public interface AdClickLogRepository extends JpaRepository { + + @Query("select l.creativeId as creativeId, count(l) as total from AdClickLog l " + + "where l.createdAt >= :from and l.createdAt < :to " + + "group by l.creativeId") + List countByCreativeBetween(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + @Query("select new com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse(" + + "l.id, c.code, c.title, c.network, l.slot, l.createdAt) " + + "from AdClickLog l join AdCreative c on c.id = l.creativeId " + + "where l.createdAt >= :from and l.createdAt < :to " + + "order by l.id desc") + Page findClickLogs(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + Pageable pageable); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java new file mode 100644 index 00000000..252d30a2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdCreativeRepository.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdCreativeRepository extends JpaRepository { + + Optional findByCode(String code); + + boolean existsByCode(String code); + + List findAllBySlotAndStatus(AdSlotCode slot, AdStatus status); + + List findAllByStatusOrderByIdDesc(AdStatus status); + + List findAllByOrderByIdDesc(); + + @Query("select c from AdCreative c " + + "where (:network is null or c.network = :network) " + + "and (:slot is null or c.slot = :slot) " + + "and (:status is null or c.status = :status) " + + "order by c.id desc") + List search(@Param("network") AdNetwork network, + @Param("slot") AdSlotCode slot, + @Param("status") AdStatus status); + + List findAllByCodeIn(List codes); + + List findAllBySource(AdSource source); +} diff --git a/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java new file mode 100644 index 00000000..fae41652 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/repository/AdImpressionDailyRepository.java @@ -0,0 +1,39 @@ +package com.swyp.picke.domain.ad.repository; + +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AdImpressionDailyRepository extends JpaRepository { + + Optional findByCreativeIdAndSlotAndStatDate(Long creativeId, AdSlotCode slot, + LocalDate statDate); + + /** + * ON CONFLICT는 PostgreSQL 전용이라 테스트 H2에서 깨진다. 갱신 후 0건이면 삽입하는 방식으로 둔다. + */ + @Modifying + @Query("update AdImpressionDaily a set a.impressions = a.impressions + :delta " + + "where a.creativeId = :creativeId and a.slot = :slot and a.statDate = :statDate") + int increment(@Param("creativeId") Long creativeId, + @Param("slot") AdSlotCode slot, + @Param("statDate") LocalDate statDate, + @Param("delta") long delta); + + @Query("select a.creativeId as creativeId, sum(a.impressions) as total from AdImpressionDaily a " + + "where a.statDate >= :from and a.statDate <= :to " + + "group by a.creativeId") + List sumByCreativeBetween(@Param("from") LocalDate from, @Param("to") LocalDate to); + + interface CreativeCount { + Long getCreativeId(); + + long getTotal(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java new file mode 100644 index 00000000..30c2fe87 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/scheduler/AdpickCampaignSyncScheduler.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ad.scheduler; + +import com.swyp.picke.domain.ad.service.AdpickCampaignSyncService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 애드픽 캠페인 주기 동기화. + * 애드픽 가이드가 최대 1분에 1회 이하 호출을 요구해 넉넉한 간격으로 돈다. + * 동기화가 실패해도 이미 저장된 소재로 광고는 계속 나가야 하므로 예외를 흘리지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AdpickCampaignSyncScheduler { + + private final AdpickCampaignSyncService adpickCampaignSyncService; + + @Scheduled( + initialDelayString = "${picke.ad.adpick.sync-initial-delay-ms:60000}", + fixedDelayString = "${picke.ad.adpick.sync-interval-ms:600000}") + public void sync() { + try { + adpickCampaignSyncService.sync(); + } catch (Exception e) { + log.warn("[AdpickSync] 캠페인 동기화 실패: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java new file mode 100644 index 00000000..7b8193f8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdClickService.java @@ -0,0 +1,92 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdClickLog; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdClickService { + + private static final int USER_AGENT_MAX_LENGTH = 500; + + /** 게재 기간 판단은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AffiliateLinkResolver affiliateLinkResolver; + + /** + * 클릭 코드로 최종 이동 대상을 찾는다. 코드가 없거나 게재 기간이 지났으면 비어 있는 값을 준다. + */ + @Transactional(readOnly = true) + public Optional resolveTarget(String code) { + return adCreativeRepository.findByCode(code) + .filter(creative -> creative.isServable(LocalDateTime.now(KST))) + .map(creative -> new AdClickTarget( + creative.getId(), + creative.getSlot(), + affiliateLinkResolver.resolve(creative) + )); + } + + /** + * 클릭 적재가 리다이렉트를 붙잡으면 안 되므로 비동기로 둔다. + * 적재에 실패해도 사용자는 정상적으로 제휴처로 이동해야 한다. + */ + @Async + @Transactional + public void recordClick(AdClickTarget target, String clientIp, String userAgent) { + try { + adClickLogRepository.save(AdClickLog.builder() + .creativeId(target.creativeId()) + .slot(target.slot()) + .ipHash(hashIp(clientIp)) + .userAgent(truncate(userAgent)) + .build()); + } catch (Exception e) { + log.warn("[AdClick] 클릭 적재 실패 creativeId={}: {}", target.creativeId(), e.getMessage()); + } + } + + /** 원본 IP는 남기지 않는다. 중복 클릭 판별에 필요한 정도만 해시로 보관한다. */ + private String hashIp(String clientIp) { + if (clientIp == null || clientIp.isBlank()) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(clientIp.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + return null; + } + } + + private String truncate(String userAgent) { + if (userAgent == null) { + return null; + } + return userAgent.length() > USER_AGENT_MAX_LENGTH + ? userAgent.substring(0, USER_AGENT_MAX_LENGTH) + : userAgent; + } + + public record AdClickTarget(Long creativeId, AdSlotCode slot, String redirectUrl) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java new file mode 100644 index 00000000..29e53142 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdCreativeCodeGenerator.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.security.SecureRandom; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 공개 클릭 URL(/c/{code})에 노출되는 짧은 코드를 만든다. + * 어드민이 눈으로 옮겨 적는 일이 있어 헷갈리기 쉬운 글자(l, o, 0, 1)를 뺀다. + */ +@Component +@RequiredArgsConstructor +public class AdCreativeCodeGenerator { + + private static final String ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789"; + private static final int LENGTH = 8; + private static final int MAX_ATTEMPTS = 10; + + private final AdCreativeRepository adCreativeRepository; + private final SecureRandom random = new SecureRandom(); + + public String generate() { + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + String code = randomCode(); + if (!adCreativeRepository.existsByCode(code)) { + return code; + } + } + throw new CustomException(ErrorCode.AD_CODE_GENERATION_FAILED); + } + + private String randomCode() { + return random.ints(LENGTH, 0, ALPHABET.length()) + .mapToObj(index -> String.valueOf(ALPHABET.charAt(index))) + .collect(Collectors.joining()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java new file mode 100644 index 00000000..137e33b7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdImpressionRecorder.java @@ -0,0 +1,48 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdImpressionDaily; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 일별 노출 집계 행을 쓰는 지점. + * 갱신과 삽입을 각각 독립 트랜잭션으로 나눠 둔다. + * + *

PostgreSQL은 한 문장이 제약을 위반하면 그 트랜잭션 전체가 abort 상태로 들어가, + * 뒤따르는 쿼리가 모두 {@code current transaction is aborted}로 실패한다. + * 삽입 실패를 같은 트랜잭션 안에서 갱신으로 되돌리려 하면 운영에서 그 복구가 동작하지 않고, + * 예외가 위로 새면 호출부가 {@code UnexpectedRollbackException}을 맞아 배치 전체가 날아간다. + * 그래서 삽입만 별도 트랜잭션에 가둬 실패를 격리한다. + */ +@Component +@RequiredArgsConstructor +public class AdImpressionRecorder { + + private final AdImpressionDailyRepository adImpressionDailyRepository; + + /** 이미 있는 행을 올린다. 대상 행이 없으면 false. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean increment(Long creativeId, AdSlotCode slot, LocalDate statDate) { + return adImpressionDailyRepository.increment(creativeId, slot, statDate, 1L) > 0; + } + + /** + * 오늘 첫 노출이라 행을 만든다. + * 같은 (소재, 지면, 날짜)를 다른 요청이 먼저 만들었으면 제약 위반으로 실패하고, + * 실패는 이 트랜잭션 안에서 끝난다. 되돌리기는 호출부가 갱신으로 처리한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void insert(Long creativeId, AdSlotCode slot, LocalDate statDate) { + adImpressionDailyRepository.saveAndFlush(AdImpressionDaily.builder() + .creativeId(creativeId) + .slot(slot) + .statDate(statDate) + .impressions(1L) + .build()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java new file mode 100644 index 00000000..5f04b72a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdQueryService.java @@ -0,0 +1,149 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdQueryService { + + /** 노출 집계 버킷과 게재 기간은 KST 기준이다. 진입점의 기본 시간대 설정에 기대지 않는다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AdCreativeRepository adCreativeRepository; + private final AdImpressionRecorder adImpressionRecorder; + + @Value("${picke.ad.base-url:https://ad.picke.store}") + private String adBaseUrl; + + /** + * 지면에 노출할 소재를 가중 로테이션으로 고른다. 게재 가능한 소재가 없으면 빈 목록을 준다. + * 앱은 빈 목록을 받으면 지면 자체를 숨긴다. 광고가 없는 건 오류가 아니다. + * 애드픽 앱 설치형 캠페인은 OS가 갈리므로, 요청 OS와 맞지 않는 소재는 제외한다. + */ + @Transactional(readOnly = true) + public List findServableAds(AdSlotCode slot, AdTargetOs os, int size) { + LocalDateTime now = LocalDateTime.now(KST); + AdTargetOs requested = os != null ? os : AdTargetOs.ALL; + + List candidates = adCreativeRepository.findAllBySlotAndStatus(slot, AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .filter(this::isShoppingOrManualAd) + .filter(creative -> creative.getTargetOs().matches(requested)) + .toList(); + + return weightedSample(candidates, size).stream() + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * ad.picke.store 루트 공개 지면에 나열할 소재. 매체 심사에서 실제 콘텐츠를 확인하므로 + * 로테이션 없이 게재 가능한 소재를 모두 보여준다. + */ + @Transactional(readOnly = true) + public List findLandingAds() { + LocalDateTime now = LocalDateTime.now(KST); + + return adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE).stream() + .filter(creative -> creative.isServable(now)) + .filter(this::isShoppingOrManualAd) + .map(creative -> AdResponse.of(creative, buildClickUrl(creative))) + .toList(); + } + + /** + * 조회 시점이 아니라 앱이 실제로 화면에 그린 시점에 호출된다. + * 조회를 노출로 세면 CTR이 실제보다 낮게 왜곡되기 때문이다. + * + *

여기에 트랜잭션을 걸지 않는다. 삽입이 제약 위반으로 실패했을 때의 되돌리기가 + * 실패한 트랜잭션 밖에서 일어나야 하고, 한 소재의 집계 실패가 나머지 소재까지 되돌리면 안 된다. + */ + public void recordImpressions(List codes) { + LocalDate today = LocalDate.now(KST); + + findImpressionTargets(codes).forEach(target -> increaseImpression(target, today)); + } + + private List findImpressionTargets(List codes) { + return adCreativeRepository.findAllByCodeIn(codes).stream() + .map(creative -> new ImpressionTarget(creative.getId(), creative.getSlot())) + .toList(); + } + + private void increaseImpression(ImpressionTarget target, LocalDate today) { + if (adImpressionRecorder.increment(target.creativeId(), target.slot(), today)) { + return; + } + + try { + adImpressionRecorder.insert(target.creativeId(), target.slot(), today); + } catch (DataIntegrityViolationException e) { + // 같은 (소재, 지면, 날짜) 행을 다른 요청이 먼저 만든 경우다. 갱신으로 되돌린다. + // 삽입이 독립 트랜잭션이라 여기서 도는 갱신은 정상 트랜잭션에서 실행된다. + adImpressionRecorder.increment(target.creativeId(), target.slot(), today); + } + } + + /** 동기화된 애드픽 설치·가입 캠페인은 제외한다. 쇼핑 식별자는 수집 시 생성한 sh + 해시다. */ + private boolean isShoppingOrManualAd(AdCreative creative) { + if (creative.getNetwork() != AdNetwork.ADPICK || creative.getSource() != AdSource.ADPICK_API) { + return true; + } + return creative.getExternalId() != null && creative.getExternalId().matches("sh[0-9a-f]{10}"); + } + + private String buildClickUrl(AdCreative creative) { + return adBaseUrl + "/c/" + creative.getCode(); + } + + private List weightedSample(List candidates, int size) { + List pool = new ArrayList<>(candidates); + List picked = new ArrayList<>(); + + while (!pool.isEmpty() && picked.size() < size) { + picked.add(pickOne(pool)); + } + return picked; + } + + /** 가중치에 비례해 하나를 뽑고 풀에서 제거한다. 같은 소재가 한 응답에 두 번 담기지 않게 한다. */ + private AdCreative pickOne(List pool) { + int totalWeight = pool.stream().mapToInt(creative -> Math.max(1, creative.getWeight())).sum(); + int threshold = ThreadLocalRandom.current().nextInt(totalWeight); + + int accumulated = 0; + for (Iterator iterator = pool.iterator(); iterator.hasNext(); ) { + AdCreative creative = iterator.next(); + accumulated += Math.max(1, creative.getWeight()); + if (threshold < accumulated) { + iterator.remove(); + return creative; + } + } + return pool.remove(pool.size() - 1); + } + + private record ImpressionTarget(Long creativeId, AdSlotCode slot) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java new file mode 100644 index 00000000..286f6a69 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncService.java @@ -0,0 +1,253 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.client.AdpickCampaignClient; +import com.swyp.picke.domain.ad.client.AdpickCampaignResponse; +import com.swyp.picke.domain.ad.client.AdpickShoppingClient; +import com.swyp.picke.domain.ad.client.AdpickShoppingResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 애드픽 캠페인을 소재로 옮겨 담는다. + * 수동 등록 소재와 같은 테이블을 쓰므로 로테이션·노출 집계·클릭 추적 경로를 그대로 탄다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdpickCampaignSyncService { + + private static final int SUBTITLE_MAX_LENGTH = 200; + private static final int TITLE_MAX_LENGTH = 100; + + private static final String SHOPPING_ID_PREFIX = "sh"; + private static final int SHOPPING_ID_LENGTH = 10; + + private final AdpickCampaignClient adpickCampaignClient; + private final AdpickShoppingClient adpickShoppingClient; + private final AdCreativeRepository adCreativeRepository; + private final AdCreativeCodeGenerator adCreativeCodeGenerator; + + @Value("${picke.ad.adpick.slot:BATTLE_RESULT_BOTTOM}") + private AdSlotCode slot; + + @Value("${picke.ad.adpick.cta-text:설치하고 받기}") + private String ctaText; + + /** + * 로테이션 가중치. 쇼핑 상품을 앱 설치형보다 자주 띄우려고 나눠 둔다. + * 앱 캠페인은 7건뿐이라 같은 가중치면 지면마다 같은 소재가 반복해서 뽑힌다. + */ + @Value("${picke.ad.adpick.weight:1}") + private int campaignWeight; + + @Value("${picke.ad.adpick.shopping-weight:3}") + private int shoppingWeight; + + /** + * 쇼핑·핫딜 상품을 흩어 놓을 지면. + * 앱 캠페인은 7건뿐이라 한 지면밖에 못 채운다. 상품 60건을 나눠 담아 나머지 지면을 채운다. + */ + @Value("${picke.ad.adpick.shopping-slots:HOME_FEED,CHAT_ROOM_INLINE,ATTENDANCE_COMPLETE,PROFILE_BOTTOM,BATTLE_RESULT_BOTTOM}") + private List shoppingSlots; + + @Value("${picke.ad.adpick.shopping-cta-text:구매하러 가기}") + private String shoppingCtaText; + + @Transactional + public int sync() { + if (!adpickCampaignClient.isConfigured()) { + log.info("[AdpickSync] affId 미설정으로 동기화를 건너뛴다."); + return 0; + } + + List campaigns = adpickCampaignClient.fetchCampaigns().stream() + .filter(AdpickCampaignResponse::isRenderable) + .toList(); + + Map existing = adCreativeRepository.findAllBySource(AdSource.ADPICK_API).stream() + .collect(Collectors.toMap(AdCreative::getExternalId, Function.identity(), (a, b) -> a)); + + Set seen = new HashSet<>(); + for (AdpickCampaignResponse campaign : campaigns) { + seen.add(campaign.offerId()); + upsert(existing.get(campaign.offerId()), campaign); + } + + int products = syncShopping(existing, seen); + + retire(existing, seen); + log.info("[AdpickSync] 캠페인 {}건, 쇼핑 {}건 동기화 완료", campaigns.size(), products); + return campaigns.size() + products; + } + + /** + * 쇼핑·핫딜 상품을 소재로 옮겨 담는다. + * + *

앱 캠페인과 같은 {@code ADPICK_API} 소스를 쓰되 식별자에 접두사를 둬서 섞이지 않게 한다. + * 소스를 새로 만들면 {@code ck_ad_creatives_source} 제약을 배포 때 사람이 직접 ALTER 해야 한다. + * 상품 하나 늘리자고 그 절차를 얹지 않는다. + * + *

상품 하나를 지면 하나에만 두면 지면당 재고가 전체의 1/지면수로 쪼개진다. + * 쇼핑 상품은 지면을 가릴 이유가 없으므로 모든 쇼핑 지면에 각각 등록해 지면마다 전량을 쓴다. + * 노출·클릭 집계는 소재 단위라, 지면별로 따로 세려면 소재도 지면별로 나뉘어 있어야 한다. + */ + private int syncShopping(Map existing, Set seen) { + if (!adpickShoppingClient.isConfigured() || shoppingSlots.isEmpty()) { + return 0; + } + + List products = adpickShoppingClient.fetchProducts().stream() + .filter(AdpickShoppingResponse::isRenderable) + .toList(); + + Set buyUrls = new HashSet<>(); + int synced = 0; + for (AdpickShoppingResponse product : products) { + if (!buyUrls.add(product.buyUrl())) { + // 쇼핑과 핫딜에 같은 상품이 함께 실릴 수 있다. 먼저 담은 쪽만 남긴다. + continue; + } + for (AdSlotCode target : shoppingSlots) { + String externalId = shoppingIdOf(product.buyUrl(), target); + seen.add(externalId); + upsertShopping(existing.get(externalId), externalId, target, product); + synced++; + } + } + return synced; + } + + private void upsertShopping( + AdCreative found, String externalId, AdSlotCode target, AdpickShoppingResponse product) { + if (found != null) { + found.syncFromAdpick( + truncate(product.productName(), TITLE_MAX_LENGTH), + truncate(product.subtitle(), SUBTITLE_MAX_LENGTH), + product.photo(), + shoppingCtaText, + product.buyUrl(), + AdTargetOs.ALL, + target, + shoppingWeight, + true); + return; + } + + adCreativeRepository.save(AdCreative.builder() + .code(adCreativeCodeGenerator.generate()) + .network(AdNetwork.ADPICK) + .slot(target) + .title(truncate(product.productName(), TITLE_MAX_LENGTH)) + .subtitle(truncate(product.subtitle(), SUBTITLE_MAX_LENGTH)) + .imageUrl(product.photo()) + .ctaText(shoppingCtaText) + .landingUrl(product.buyUrl()) + .status(AdStatus.ACTIVE) + .weight(shoppingWeight) + .source(AdSource.ADPICK_API) + .externalId(externalId) + .targetOs(AdTargetOs.ALL) + .build()); + } + + /** + * 상품에는 애드픽이 주는 코드가 없어 구매 링크에서 식별자를 만든다. + * 링크가 그대로면 같은 소재로 갱신되고, 바뀌면 새 소재가 된다. + * + *

같은 상품이 지면마다 따로 등록되므로 지면까지 해시에 넣는다. + * 길이와 모양({@code sh} + 16진수 10자)은 그대로 둔다. 조회 쪽이 이 형태로 쇼핑 소재를 가려낸다. + */ + private String shoppingIdOf(String buyUrl, AdSlotCode slot) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest((buyUrl + "|" + slot.name()).getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(SHOPPING_ID_PREFIX); + for (int i = 0; hex.length() < SHOPPING_ID_PREFIX.length() + SHOPPING_ID_LENGTH; i++) { + hex.append(String.format("%02x", digest[i])); + } + return hex.substring(0, SHOPPING_ID_PREFIX.length() + SHOPPING_ID_LENGTH); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 을 쓸 수 없다", e); + } + } + + private void upsert(AdCreative found, AdpickCampaignResponse campaign) { + if (found != null) { + found.syncFromAdpick( + truncate(campaign.appTitle(), TITLE_MAX_LENGTH), + subtitleOf(campaign), + campaign.iconUrl(), + ctaText, + campaign.trackingLink(), + AdTargetOs.fromAdpick(campaign.os()), + slot, + campaignWeight, + campaign.hasRemaining()); + return; + } + + adCreativeRepository.save(AdCreative.builder() + .code(adCreativeCodeGenerator.generate()) + .network(AdNetwork.ADPICK) + .slot(slot) + .title(truncate(campaign.appTitle(), TITLE_MAX_LENGTH)) + .subtitle(subtitleOf(campaign)) + .imageUrl(campaign.iconUrl()) + .ctaText(ctaText) + .landingUrl(campaign.trackingLink()) + .status(campaign.hasRemaining() ? AdStatus.ACTIVE : AdStatus.DRAFT) + .weight(campaignWeight) + .source(AdSource.ADPICK_API) + .externalId(campaign.offerId()) + .targetOs(AdTargetOs.fromAdpick(campaign.os())) + .build()); + } + + /** + * 피드에서 사라진 캠페인은 지우지 않고 내린다. + * 이미 쌓인 노출·클릭 집계가 어느 소재의 것인지 계속 읽을 수 있어야 한다. + */ + private void retire(Map existing, Set seen) { + existing.forEach((externalId, creative) -> { + if (!seen.contains(externalId) && creative.getStatus() == AdStatus.ACTIVE) { + creative.changeStatus(AdStatus.DRAFT); + } + }); + } + + private String subtitleOf(AdpickCampaignResponse campaign) { + String source = campaign.headline() != null && !campaign.headline().isBlank() + ? campaign.headline() + : campaign.promoText(); + if (source == null || source.isBlank()) { + return null; + } + return truncate(source.replaceAll("\\s+", " ").trim(), SUBTITLE_MAX_LENGTH); + } + + private String truncate(String value, int max) { + if (value == null) { + return null; + } + return value.length() > max ? value.substring(0, max) : value; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java new file mode 100644 index 00000000..30a1a10c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClient.java @@ -0,0 +1,164 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +public class AdfitAccountReportClient { + private static final String REPORT_URL = + "https://adfit.kakao.com/api/v2/report/accountTotal/periodicIndicators"; + + /** 쿠키는 조회할 때마다 다시 읽는다. 관리자가 만료된 값을 갈아끼우면 재배포 없이 바로 반영돼야 한다. */ + private final Supplier sessionCookieSupplier; + private final AdfitHttpTransport transport; + private final ObjectMapper objectMapper; + + @Autowired + public AdfitAccountReportClient(AdfitSessionCookieStore cookieStore, AdfitHttpTransport transport) { + this(cookieStore::current, transport, new ObjectMapper()); + } + + AdfitAccountReportClient(Supplier sessionCookieSupplier, AdfitHttpTransport transport, + ObjectMapper objectMapper) { + this.sessionCookieSupplier = sessionCookieSupplier; + this.transport = transport; + this.objectMapper = objectMapper; + } + + public AdfitReport.AccountReport fetch(LocalDate from, LocalDate to, long expectedDays) { + validateRange(from, to); + List emptyDays = nullDays(from, to); + String sessionCookie = sessionCookieSupplier.get(); + if (!StringUtils.hasText(sessionCookie)) { + return report(AdfitAccountReportStatus.NOT_CONFIGURED, null, expectedDays, null, emptyDays); + } + + AdfitHttpResponse response = transport.get(uri(from, to), sessionCookie); + if (response.statusCode() == 401 || response.statusCode() == 403 || response.statusCode() == 419 + || response.isRedirect() + || looksLikeHtml(response.body())) { + return report(AdfitAccountReportStatus.RECONNECT_REQUIRED, null, expectedDays, null, emptyDays); + } + if (response.statusCode() < 200 || response.statusCode() >= 300 || !response.isJson()) { + return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays); + } + + try { + List days = parseDays(response.body(), from, to); + BigDecimal revenue = days.stream() + .map(AdfitReport.AccountDay::revenue) + .filter(value -> value != null) + .reduce(BigDecimal.ZERO, BigDecimal::add); + long reportedDays = days.stream().filter(day -> day.revenue() != null).count(); + return new AdfitReport.AccountReport(AdfitAccountReportStatus.CONNECTED, Instant.now(), + reportedDays, expectedDays, reportedDays == 0 ? null : revenue, null, null, days); + } catch (Exception e) { + log.warn("[AdFit] 계정 수익 보고서 응답 파싱 실패: {}", e.getClass().getSimpleName()); + return report(AdfitAccountReportStatus.UNAVAILABLE, null, expectedDays, null, emptyDays); + } + } + + private URI uri(LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(REPORT_URL) + .queryParam("dayType", "DAY") + .queryParam("startDate", from) + .queryParam("endDate", to) + .build() + .toUri(); + } + + private List parseDays(String body, LocalDate from, LocalDate to) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("AdFit report root must be an array."); + } + + Map byDate = new HashMap<>(); + for (JsonNode node : root) { + LocalDate date = LocalDate.parse(requiredText(node, "reportDate")); + if (date.isBefore(from) || date.isAfter(to)) { + throw new IllegalArgumentException("AdFit report date is out of range."); + } + if (!node.has("profit")) { + throw new IllegalArgumentException("AdFit report profit is missing."); + } + AdfitReport.AccountDay day = new AdfitReport.AccountDay(date, decimalOrNull(node, "profit"), + decimalOrNull(node, "ctr"), decimalOrNull(node, "ecpm"), + decimalOrNull(node, "fillRate"), decimalOrNull(node, "winFillRate")); + if (byDate.putIfAbsent(date, day) != null) { + throw new IllegalArgumentException("AdFit report date is duplicated."); + } + } + + List days = new ArrayList<>(); + LocalDate cursor = to; + while (!cursor.isBefore(from)) { + days.add(byDate.getOrDefault(cursor, + new AdfitReport.AccountDay(cursor, null, null, null, null, null))); + cursor = cursor.minusDays(1); + } + return days; + } + + private String requiredText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull() || !value.isTextual()) { + throw new IllegalArgumentException("AdFit report " + field + " is invalid."); + } + return value.asText(); + } + + private BigDecimal decimalOrNull(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isNumber()) { + throw new IllegalArgumentException("AdFit report " + field + " is invalid."); + } + return value.decimalValue(); + } + + private List nullDays(LocalDate from, LocalDate to) { + List days = new ArrayList<>(); + LocalDate cursor = to; + while (!cursor.isBefore(from)) { + days.add(new AdfitReport.AccountDay(cursor, null, null, null, null, null)); + cursor = cursor.minusDays(1); + } + return days; + } + + private AdfitReport.AccountReport report(AdfitAccountReportStatus status, Instant fetchedAt, + long expectedDays, BigDecimal revenue, + List days) { + return new AdfitReport.AccountReport(status, fetchedAt, 0, expectedDays, revenue, null, null, days); + } + + private void validateRange(LocalDate from, LocalDate to) { + long expected = ChronoUnit.DAYS.between(from, to) + 1; + if (expected < 1 || expected > 366) { + throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다."); + } + } + + private boolean looksLikeHtml(String body) { + return body != null && body.stripLeading().startsWith("<"); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java new file mode 100644 index 00000000..63aa7ec6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportStatus.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.adfit; + +public enum AdfitAccountReportStatus { + NOT_CONFIGURED, + CONNECTED, + RECONNECT_REQUIRED, + UNAVAILABLE +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java new file mode 100644 index 00000000..33162dcd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitCostBasis.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.admin.adfit; + +public enum AdfitCostBasis { + AD_OPERATIONS, ACQUISITION, SERVICE_OPERATIONS +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java new file mode 100644 index 00000000..f80b299c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDaily.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AccessLevel; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "adfit_daily_reports", uniqueConstraints = @UniqueConstraint( + name = "uk_adfit_daily_date_unit", columnNames = {"report_date", "ad_unit"})) +public class AdfitDaily extends BaseEntity { + @Column(name = "report_date", nullable = false) + private LocalDate date; + @Enumerated(EnumType.STRING) + @Column(name = "ad_unit", nullable = false, length = 30) + private AdfitUnit unit; + @Column(nullable = false, precision = 16, scale = 2) + private BigDecimal revenue; + @Column(nullable = false, precision = 16, scale = 2) + private BigDecimal cost; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private AdfitCostBasis costBasis; + + public AdfitDaily(LocalDate date, AdfitUnit unit) { + this.date = date; + this.unit = unit; + } + + public void update(BigDecimal revenue, BigDecimal cost, AdfitCostBasis costBasis) { + this.revenue = revenue; + this.cost = cost; + this.costBasis = costBasis; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java new file mode 100644 index 00000000..23cbfa6c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRepository.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdfitDailyRepository extends JpaRepository { + Optional findByDateAndUnit(LocalDate date, AdfitUnit unit); + List findAllByDateBetweenOrderByDateDesc(LocalDate from, LocalDate to); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java new file mode 100644 index 00000000..a4b5cf25 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitDailyRequest.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.admin.adfit; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; + +public record AdfitDailyRequest( + @NotNull LocalDate date, + @NotNull AdfitUnit unit, + @NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal revenue, + @NotNull @DecimalMin("0") @Digits(integer = 14, fraction = 2) BigDecimal cost, + @NotNull AdfitCostBasis costBasis +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java new file mode 100644 index 00000000..5fa96ade --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.List; +import java.util.Map; + +record AdfitHttpResponse(int statusCode, Map> headers, String body) { + boolean isRedirect() { + return statusCode >= 300 && statusCode < 400; + } + + boolean isJson() { + return headers.entrySet().stream() + .filter(entry -> "content-type".equalsIgnoreCase(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .anyMatch(value -> value.toLowerCase().contains("application/json")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java new file mode 100644 index 00000000..c32c4d19 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitHttpTransport.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.net.URI; + +interface AdfitHttpTransport { + AdfitHttpResponse get(URI uri, String sessionCookie); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java new file mode 100644 index 00000000..acbe2c02 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReport.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +public record AdfitReport(LocalDate from, LocalDate to, String source, + List units, List days, AccountReport account) { + public record UnitReport(AdfitUnit unit, String name, List placements, String format, + long reportedDays, long expectedDays, BigDecimal revenue, + BigDecimal cost, BigDecimal roi) {} + public record Day(LocalDate date, AdfitUnit unit, BigDecimal revenue, BigDecimal cost, + AdfitCostBasis costBasis) {} + public record AccountReport(AdfitAccountReportStatus status, Instant fetchedAt, + long reportedDays, long expectedDays, BigDecimal revenue, + BigDecimal cost, BigDecimal roi, List days) {} + public record AccountDay(LocalDate date, BigDecimal revenue, BigDecimal ctr, BigDecimal ecpm, + BigDecimal fillRate, BigDecimal winFillRate) {} +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java new file mode 100644 index 00000000..844657c7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitReportService.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdfitReportService { + private final AdfitDailyRepository repository; + private final AdfitAccountReportClient accountReportClient; + private final AdfitSessionCookieStore sessionCookieStore; + + public AdfitSessionCookieStatus sessionCookieStatus() { + return sessionCookieStore.status(); + } + + public void updateSessionCookie(AdfitSessionCookieRequest request) { + sessionCookieStore.update(request.cookie()); + } + + @Transactional + public void save(AdfitDailyRequest request) { + if (request.date().isAfter(LocalDate.now(ZoneId.of("Asia/Seoul")))) { + throw new IllegalArgumentException("미래 날짜의 수익은 입력할 수 없습니다."); + } + AdfitDaily daily = repository.findByDateAndUnit(request.date(), request.unit()) + .orElseGet(() -> new AdfitDaily(request.date(), request.unit())); + daily.update(request.revenue(), request.cost(), request.costBasis()); + repository.save(daily); + } + + public AdfitReport report(LocalDate from, LocalDate to) { + long expected = ChronoUnit.DAYS.between(from, to) + 1; + if (expected < 1 || expected > 366) { + throw new IllegalArgumentException("조회 기간은 1일부터 366일까지입니다."); + } + List days = repository.findAllByDateBetweenOrderByDateDesc(from, to); + List units = Arrays.stream(AdfitUnit.values()).map(unit -> { + List entries = days.stream().filter(day -> day.getUnit() == unit).toList(); + BigDecimal revenue = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getRevenue) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal cost = entries.isEmpty() ? null : entries.stream().map(AdfitDaily::getCost) + .reduce(BigDecimal.ZERO, BigDecimal::add); + // 기간이 빠졌거나 비용 기준이 섞이면 비교 가능한 ROI가 아니다. + boolean complete = entries.size() == expected + && entries.stream().map(AdfitDaily::getCostBasis).distinct().count() == 1; + BigDecimal roi = complete && cost != null && cost.signum() > 0 + ? revenue.subtract(cost).multiply(BigDecimal.valueOf(100)) + .divide(cost, 2, RoundingMode.HALF_UP) : null; + return new AdfitReport.UnitReport(unit, unit.getDisplayName(), unit.getPlacements(), + unit.getFormat(), entries.size(), expected, revenue, cost, roi); + }).toList(); + AdfitReport.AccountReport account = accountReportClient.fetch(from, to, expected); + return new AdfitReport(from, to, "MANUAL_CONSOLE", units, days.stream().map(day -> + new AdfitReport.Day(day.getDate(), day.getUnit(), day.getRevenue(), day.getCost(), + day.getCostBasis())).toList(), account); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java new file mode 100644 index 00000000..1af390a1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookie.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AdFit 콘솔 세션 쿠키. + * + *

AdFit은 매체주 수익을 읽는 공개 REST API가 없어 콘솔 내부 API를 세션 쿠키로 호출한다. + * 이 쿠키는 수시로 만료되는데 값이 환경변수에만 있으면 만료마다 재배포를 해야 한다. + * 관리자가 화면에서 갈아끼울 수 있도록 한 행으로 보관한다. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "adfit_session_cookies") +public class AdfitSessionCookie extends BaseEntity { + + @Column(name = "cookie_value", nullable = false, length = 4096) + private String value; + + public AdfitSessionCookie(String value) { + this.value = value; + } + + public void update(String value) { + this.value = value; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java new file mode 100644 index 00000000..8346a918 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRepository.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdfitSessionCookieRepository extends JpaRepository { + /** 한 행만 두고 쓰지만, 저장이 겹쳐 행이 늘어도 마지막 값이 이기게 한다. */ + Optional findTopByOrderByIdDesc(); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java new file mode 100644 index 00000000..c8c8b5ed --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.adfit; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * @param cookie AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체. {@code name=value; name2=value2} 형태. + */ +public record AdfitSessionCookieRequest( + @NotBlank @Size(max = 4096) String cookie) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java new file mode 100644 index 00000000..f98e1ae7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieSource.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.adfit; + +public enum AdfitSessionCookieSource { + /** 관리자가 화면에서 넣은 값. 환경변수보다 앞선다. */ + ADMIN_CONSOLE, + /** 배포 시 주입한 환경변수 값. */ + ENVIRONMENT, + /** 양쪽 모두 없음. 자동 수익 조회가 NOT_CONFIGURED 가 된다. */ + NONE +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java new file mode 100644 index 00000000..8647acf4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStatus.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.time.LocalDateTime; + +/** + * 쿠키 보유 여부와 마지막 갱신 시각. 쿠키 값 자체는 담지 않는다. + * + * @param updatedAt 관리자가 마지막으로 넣은 시각. 환경변수 값을 쓰는 중이면 null. + */ +public record AdfitSessionCookieStatus( + boolean configured, + AdfitSessionCookieSource source, + LocalDateTime updatedAt) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java new file mode 100644 index 00000000..ccd289e1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStore.java @@ -0,0 +1,60 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 자동 수익 조회에 쓸 AdFit 세션 쿠키를 고른다. + * + *

관리자가 화면에서 넣은 값이 환경변수보다 앞선다. 만료될 때 재배포 없이 갈아끼우는 게 목적이다. + * 값은 어떤 응답에도 담지 않는다. 콘솔 로그인 자격이라 읽히면 계정이 그대로 열린다. + */ +@Component +@RequiredArgsConstructor +public class AdfitSessionCookieStore { + + private final AdfitSessionCookieRepository repository; + + @Value("${picke.adfit.session-cookie:${ADFIT_SESSION_COOKIE:}}") + private String environmentCookie; + + @Transactional(readOnly = true) + public String current() { + return stored() + .map(AdfitSessionCookie::getValue) + .orElse(environmentCookie); + } + + @Transactional + public void update(String cookie) { + String trimmed = cookie == null ? "" : cookie.trim(); + if (!trimmed.contains("=")) { + // 브라우저에서 Cookie 헤더가 아닌 걸 붙여넣은 경우다. 조회가 조용히 실패하게 두지 않는다. + throw new IllegalArgumentException("세션 쿠키는 name=value 형태여야 합니다."); + } + AdfitSessionCookie target = repository.findTopByOrderByIdDesc() + .orElseGet(() -> new AdfitSessionCookie(trimmed)); + target.update(trimmed); + repository.save(target); + } + + /** 화면에 쿠키가 있는지와 마지막 갱신 시각만 준다. 값은 주지 않는다. */ + @Transactional(readOnly = true) + public AdfitSessionCookieStatus status() { + return stored() + .map(cookie -> new AdfitSessionCookieStatus( + true, AdfitSessionCookieSource.ADMIN_CONSOLE, cookie.getUpdatedAt())) + .orElseGet(() -> StringUtils.hasText(environmentCookie) + ? new AdfitSessionCookieStatus(true, AdfitSessionCookieSource.ENVIRONMENT, null) + : new AdfitSessionCookieStatus(false, AdfitSessionCookieSource.NONE, null)); + } + + private Optional stored() { + return repository.findTopByOrderByIdDesc() + .filter(cookie -> StringUtils.hasText(cookie.getValue())); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java new file mode 100644 index 00000000..a94df626 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdfitUnit.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AdfitUnit { + NATIVE_WIDE("네이티브 광고", List.of("홈", "큐레이션", "마이페이지"), "네이티브 2:1"), + BANNER("탐색 배너", List.of("탐색"), "배너 320×100"), + APP_TRANSITION("앱 시작 팝업", List.of("앱 시작"), "전면 팝업"); + + private final String displayName; + private final List placements; + private final String format; +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java new file mode 100644 index 00000000..f6c8b4ab --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/AdminAdfitController.java @@ -0,0 +1,49 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/adfit") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "관리자 AdFit", description = "AdFit 계정 자동 수익 및 수동 입력 비용·ROI") +public class AdminAdfitController { + private final AdfitReportService service; + + @GetMapping + @Operation(summary = "AdFit 기간별 수익·ROI", description = "account는 AdFit 계정 자동 수익, units/days는 수동 입력 비용·ROI. 미입력·계산 불가는 null") + public ApiResponse report( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + return ApiResponse.onSuccess(service.report(from, to)); + } + + @GetMapping("/session-cookie") + @Operation(summary = "AdFit 세션 쿠키 상태", description = "쿠키 보유 여부와 마지막 갱신 시각만 준다. 쿠키 값은 응답에 담지 않는다") + public ApiResponse sessionCookieStatus() { + return ApiResponse.onSuccess(service.sessionCookieStatus()); + } + + @PutMapping("/session-cookie") + @Operation(summary = "AdFit 세션 쿠키 갱신", + description = "AdFit 콘솔에 로그인한 브라우저의 Cookie 헤더 전체를 넣는다. 재배포 없이 자동 수익 조회가 다시 붙는다") + public ApiResponse updateSessionCookie(@Valid @RequestBody AdfitSessionCookieRequest request) { + service.updateSessionCookie(request); + return ApiResponse.onSuccess(null); + } + + @PutMapping("/daily") + @Operation(summary = "광고 단위별 일일 수익·배분 비용 저장", description = "같은 날짜·광고 단위는 수정한다. 비용을 여러 단위에 중복 입력하지 않는다.") + public ApiResponse save(@Valid @RequestBody AdfitDailyRequest request) { + service.save(request); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java new file mode 100644 index 00000000..02c74050 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/adfit/JavaNetAdfitHttpTransport.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +class JavaNetAdfitHttpTransport implements AdfitHttpTransport { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(8); + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + @Override + public AdfitHttpResponse get(URI uri, String sessionCookie) { + try { + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json") + .header("Referer", "https://adfit.kakao.com/report") + .header("Cookie", sessionCookie) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return new AdfitHttpResponse(response.statusCode(), response.headers().map(), response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("interrupted")), ""); + } catch (Exception e) { + return new AdfitHttpResponse(0, Map.of("x-adfit-error", List.of("request_failed")), ""); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java new file mode 100644 index 00000000..a2948316 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsController.java @@ -0,0 +1,56 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/analytics") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "관리자 지표", description = "Mixpanel 이벤트와 Sentry 사용자 이벤트·오류를 관리자 화면에서 함께 본다") +public class AdminAnalyticsController { + + private static final long MAX_DAYS = 366; + + private final MixpanelClient mixpanelClient; + private final SentryClient sentryClient; + + @GetMapping("/mixpanel") + @Operation(summary = "Mixpanel 이벤트 일별 발생 수", + description = "events 를 비우면 기간에 나타난 이벤트 전부를 센다. 이벤트·사용자·가입 집계를 함께 반환한다. 원본 이벤트를 받아 세므로 기간은 31일까지") + public ApiResponse mixpanel( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(required = false) List events) { + validateRange(from, to, MixpanelClient.MAX_DAYS); + return ApiResponse.onSuccess(mixpanelClient.fetchDailyCounts(events, from, to)); + } + + @GetMapping("/sentry") + @Operation(summary = "Sentry 사용자 이벤트와 프로젝트 관측 데이터", + description = "analytics_event 태그의 모든 사용자 이벤트와 오류·로그·성능·세션·릴리즈를 프로젝트별로 제공한다. 토큰·프로젝트 설정이 없으면 NOT_CONFIGURED") + public ApiResponse sentry( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + validateRange(from, to, MAX_DAYS); + return ApiResponse.onSuccess(sentryClient.fetchUnresolvedIssues(from, to)); + } + + private void validateRange(LocalDate from, LocalDate to, long maxDays) { + long days = ChronoUnit.DAYS.between(from, to) + 1; + if (days < 1 || days > maxDays) { + throw new IllegalArgumentException("조회 기간은 1일부터 " + maxDays + "일까지입니다."); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java new file mode 100644 index 00000000..6d8b58c1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.util.List; +import java.util.Map; + +record AnalyticsHttpResponse(int statusCode, Map> headers, String body) { + boolean isSuccess() { + return statusCode >= 200 && statusCode < 300; + } + + boolean isJson() { + return headers.entrySet().stream() + .filter(entry -> "content-type".equalsIgnoreCase(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .anyMatch(value -> value.toLowerCase().contains("application/json")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java new file mode 100644 index 00000000..ce87a145 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsHttpTransport.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.net.URI; +import java.util.Map; + +interface AnalyticsHttpTransport { + AnalyticsHttpResponse get(URI uri, Map headers); +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java new file mode 100644 index 00000000..71d2c31b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/AnalyticsStatus.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.analytics; + +public enum AnalyticsStatus { + /** 토큰·프로젝트 설정이 없다. 지표를 비워 보여주고 오류로 다루지 않는다. */ + NOT_CONFIGURED, + CONNECTED, + /** 토큰 거절, 외부 장애, 응답 스키마 불일치. 0으로 대체하지 않는다. */ + UNAVAILABLE +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java new file mode 100644 index 00000000..e9d14cf5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/JavaNetAnalyticsHttpTransport.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +@Component +class JavaNetAnalyticsHttpTransport implements AnalyticsHttpTransport { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + /** Mixpanel 원본 이벤트는 한 달치가 수십 MB 가 된다. 내려받는 시간을 감당할 만큼 둔다. */ + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60); + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + @Override + public AnalyticsHttpResponse get(URI uri, Map headers) { + try { + HttpRequest.Builder request = HttpRequest.newBuilder(uri) + .timeout(REQUEST_TIMEOUT) + .header("Accept", "application/json"); + headers.forEach(request::header); + HttpResponse response = client.send(request.GET().build(), + HttpResponse.BodyHandlers.ofString()); + return new AnalyticsHttpResponse(response.statusCode(), response.headers().map(), response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new AnalyticsHttpResponse(0, Map.of("x-analytics-error", List.of("interrupted")), ""); + } catch (Exception e) { + // 관리자 지표 조회다. 외부 장애가 관리자 화면 전체를 500 으로 만들지 않게 한다. + return new AnalyticsHttpResponse(0, Map.of("x-analytics-error", List.of("request_failed")), ""); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java new file mode 100644 index 00000000..e5fb9491 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelClient.java @@ -0,0 +1,248 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Mixpanel 원본 이벤트를 내려받아 서버가 일별로 센다. + * + *

집계 API(segmentation·insights)를 쓰지 않는다. 현재 프로젝트 플랜이 그쪽을 막아 + * {@code HTTP 402 Your plan does not allow API calls} 를 준다. 반면 Raw Export + * ({@code data.mixpanel.com/api/2.0/export}) 는 같은 플랜에서 열려 있어 여기서 직접 센다. + * + *

인증은 프로젝트 API 비밀을 Basic 사용자명 자리에 넣는 레거시 방식이다. + * 이 방식에는 {@code project_id} 파라미터를 넣으면 400 이 되므로 넣지 않는다. + * 비밀이 이미 프로젝트를 특정한다. + * + *

응답은 한 줄에 이벤트 하나인 NDJSON 이다. {@code properties.time} 은 프로젝트 타임존 + * 기준 epoch 초이며, 요청의 {@code from_date}·{@code to_date} 경계도 같은 타임존을 따른다. + * 둘을 같은 타임존으로 묶어야 날짜 버킷이 Mixpanel 화면 숫자와 맞는다. + */ +@Slf4j +@Component +public class MixpanelClient { + + /** 원본 이벤트를 전부 받으므로 기간이 넓으면 응답이 급격히 커진다. */ + static final long MAX_DAYS = 31; + + private final String baseUrl; + private final String apiSecret; + private final ZoneId projectZone; + private final AnalyticsHttpTransport transport; + private final ObjectMapper objectMapper; + + @Autowired + public MixpanelClient( + @Value("${picke.analytics.mixpanel.base-url:https://data.mixpanel.com}") String baseUrl, + @Value("${picke.analytics.mixpanel.api-secret:${MIXPANEL_API_SECRET:}}") String apiSecret, + @Value("${picke.analytics.mixpanel.project-zone:UTC}") String projectZone, + @Value("${picke.analytics.mixpanel.default-events:}") List defaultEvents, + AnalyticsHttpTransport transport) { + this(baseUrl, apiSecret, ZoneId.of(projectZone), defaultEvents, transport, new ObjectMapper()); + } + + MixpanelClient(String baseUrl, String apiSecret, ZoneId projectZone, List defaultEvents, + AnalyticsHttpTransport transport, ObjectMapper objectMapper) { + this.baseUrl = baseUrl; + this.apiSecret = apiSecret; + this.projectZone = projectZone; + this.transport = transport; + this.objectMapper = objectMapper; + } + + /** + * @param requestedEvents 빈 값이면 기간에 실제로 나타난 이벤트 전부를 센다. + */ + public MixpanelEventReport fetchDailyCounts(List requestedEvents, LocalDate from, LocalDate to) { + if (!StringUtils.hasText(apiSecret)) { + return MixpanelEventReport.empty(AnalyticsStatus.NOT_CONFIGURED, from, to); + } + + AnalyticsHttpResponse response = transport.get(uri(from, to), + Map.of("Authorization", "Basic " + basicCredentials())); + if (!response.isSuccess()) { + log.warn("[Mixpanel] 원본 이벤트 조회 실패: status={}", response.statusCode()); + return MixpanelEventReport.empty(AnalyticsStatus.UNAVAILABLE, from, to); + } + + try { + Aggregation aggregation = aggregate(response.body(), targetEvents(requestedEvents), from, to); + return new MixpanelEventReport(AnalyticsStatus.CONNECTED, Instant.now(), from, to, + aggregation.totalEvents(), aggregation.uniqueUsers(), to, + aggregation.activeUsers(), aggregation.signUps(), aggregation.signUpDays(), + aggregation.availableEvents(), aggregation.events()); + } catch (Exception e) { + log.warn("[Mixpanel] 원본 이벤트 파싱 실패: {}", e.getClass().getSimpleName()); + return MixpanelEventReport.empty(AnalyticsStatus.UNAVAILABLE, from, to); + } + } + + private List targetEvents(List requestedEvents) { + if (requestedEvents != null && !requestedEvents.isEmpty()) { + return requestedEvents; + } + return List.of(); + } + + /** + * NDJSON 을 한 줄씩 세어 이벤트×날짜 표를 만든다. + * + *

지정한 이벤트가 없으면 기간에 나타난 이벤트를 전부 센다. 원본을 받았으므로 + * 어떤 이벤트가 있었는지 서버가 알 수 있다. 이름을 미리 설정해 둘 필요가 없다. + */ + private Aggregation aggregate( + String body, List targets, LocalDate from, LocalDate to) throws Exception { + Set wanted = targets.isEmpty() ? null : new LinkedHashSet<>(targets); + Map stats = new HashMap<>(); + Set availableEvents = new HashSet<>(); + Set reportUsers = new HashSet<>(); + Set activeUsers = new HashSet<>(); + Map signUpsByDate = new HashMap<>(); + long signUps = 0; + + for (String line : body.split("\n")) { + if (line.isBlank()) { + continue; + } + JsonNode node = objectMapper.readTree(line); + String event = node.path("event").asText(null); + JsonNode time = node.path("properties").path("time"); + if (event == null || !time.isNumber()) { + throw new IllegalArgumentException("Mixpanel export line is missing event or time."); + } + availableEvents.add(event); + Instant occurredAt = Instant.ofEpochSecond(time.asLong()); + LocalDate date = occurredAt.atZone(projectZone).toLocalDate(); + if (date.isBefore(from) || date.isAfter(to)) { + // 경계 하루가 타임존 차이로 걸쳐 들어올 수 있다. 요청 기간 밖은 버린다. + continue; + } + String distinctId = text(node.path("properties").get("distinct_id")); + if (date.equals(to)) { + if (distinctId != null) { + activeUsers.add(distinctId); + } + if ("sign_up".equals(event)) { + signUps++; + } + } + if ("sign_up".equals(event)) { + signUpsByDate.merge(date, 1L, Long::sum); + } + if (wanted != null && !wanted.contains(event)) { + continue; + } + EventStats eventStats = stats.computeIfAbsent(event, key -> new EventStats()); + eventStats.add(date, occurredAt, distinctId); + if (distinctId != null) { + reportUsers.add(distinctId); + } + } + + List events = wanted != null ? List.copyOf(wanted) : new ArrayList<>(stats.keySet()); + List series = events.stream() + .map(event -> series(event, stats.getOrDefault(event, new EventStats()), from, to)) + .sorted(Comparator.comparing(MixpanelEventReport.EventSeries::total).reversed()) + .toList(); + long total = series.stream().mapToLong(MixpanelEventReport.EventSeries::total).sum(); + return new Aggregation(total, (long) reportUsers.size(), (long) activeUsers.size(), signUps, + signUpDays(signUpsByDate, from, to), + availableEvents.stream().sorted().toList(), series); + } + + private List signUpDays( + Map counts, LocalDate from, LocalDate to) { + List days = new ArrayList<>(); + for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) { + days.add(new MixpanelEventReport.SignUpDay(cursor, counts.getOrDefault(cursor, 0L))); + } + return days; + } + + /** 원본을 전부 받았으므로 이벤트가 없던 날짜는 미집계가 아니라 0 이다. */ + private MixpanelEventReport.EventSeries series( + String event, EventStats stats, LocalDate from, LocalDate to) { + List days = new ArrayList<>(); + for (LocalDate cursor = to; !cursor.isBefore(from); cursor = cursor.minusDays(1)) { + days.add(new MixpanelEventReport.Day( + cursor, + stats.counts.getOrDefault(cursor, 0L), + (long) stats.users.getOrDefault(cursor, Set.of()).size())); + } + return new MixpanelEventReport.EventSeries( + event, stats.total, (long) stats.allUsers.size(), stats.firstSeen, stats.lastSeen, days); + } + + private String text(JsonNode value) { + return value == null || value.isNull() || value.isContainerNode() ? null : value.asText(); + } + + private record Aggregation( + Long totalEvents, + Long uniqueUsers, + Long activeUsers, + Long signUps, + List signUpDays, + List availableEvents, + List events) { + } + + private static final class EventStats { + private final Map counts = new HashMap<>(); + private final Map> users = new HashMap<>(); + private final Set allUsers = new HashSet<>(); + private long total; + private Instant firstSeen; + private Instant lastSeen; + + private void add(LocalDate date, Instant occurredAt, String distinctId) { + counts.merge(date, 1L, Long::sum); + total++; + if (distinctId != null) { + allUsers.add(distinctId); + users.computeIfAbsent(date, key -> new HashSet<>()).add(distinctId); + } + if (firstSeen == null || occurredAt.isBefore(firstSeen)) { + firstSeen = occurredAt; + } + if (lastSeen == null || occurredAt.isAfter(lastSeen)) { + lastSeen = occurredAt; + } + } + } + + private String basicCredentials() { + // 레거시 방식은 API 비밀을 사용자명 자리에 두고 비밀번호를 비운다. + return Base64.getEncoder() + .encodeToString((apiSecret + ":").getBytes(StandardCharsets.UTF_8)); + } + + private URI uri(LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/2.0/export") + .queryParam("from_date", from) + .queryParam("to_date", to) + .build() + .toUri(); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java new file mode 100644 index 00000000..d446e071 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/MixpanelEventReport.java @@ -0,0 +1,49 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** + * Mixpanel 이벤트 일별 발생 수. 원본 이벤트를 내려받아 서버가 센 값이다. + * + * @param availableEvents 조회 기간 원본에 나타난 전체 이벤트 이름. + * @param events 발생 수 내림차순. 조회할 이벤트를 지정하지 않으면 기간에 나타난 이벤트 전부다. + */ +public record MixpanelEventReport( + AnalyticsStatus status, + Instant fetchedAt, + LocalDate from, + LocalDate to, + Long totalEvents, + Long uniqueUsers, + LocalDate summaryDate, + Long activeUsers, + Long signUps, + List signUpDays, + List availableEvents, + List events) { + + public record EventSeries( + String event, + Long total, + Long uniqueUsers, + Instant firstSeen, + Instant lastSeen, + List days) { + } + + /** + * @param count 해당 날짜 발생 수. 원본을 전부 받아 세므로 이벤트가 없던 날짜는 미집계가 아니라 0 이다. + */ + public record Day(LocalDate date, Long count, Long uniqueUsers) { + } + + public record SignUpDay(LocalDate date, Long count) { + } + + static MixpanelEventReport empty(AnalyticsStatus status, LocalDate from, LocalDate to) { + return new MixpanelEventReport( + status, null, from, to, null, null, to, null, null, List.of(), List.of(), List.of()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java new file mode 100644 index 00000000..aa279d47 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryClient.java @@ -0,0 +1,690 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Sentry 프로젝트의 오류·로그·성능·프로파일·메트릭·세션·릴리즈 데이터를 읽는다. + * + *

인증은 조직 인증 토큰(Bearer)이다. 토큰이 없으면 조회하지 않고 NOT_CONFIGURED 를 준다. + * 기간을 절대 날짜로 넘기려면 statsPeriod 를 빈 값으로 함께 보내야 한다. + */ +@Slf4j +@Component +public class SentryClient { + + private static final int ISSUE_LIMIT = 20; + /** full=true 는 Sentry가 페이지 크기를 최대 10건으로 제한한다. */ + private static final int RECENT_EVENT_LIMIT = 10; + private static final int ANALYTICS_EVENT_BATCH_SIZE = 10; + private static final String ANALYTICS_EVENT_FIELD = "tag[analytics_event,string]"; + private static final List DATASETS = List.of( + "errors", "logs", "spans", "profile_functions", "tracemetrics"); + + private final String baseUrl; + private final String organization; + private final List projects; + private final String authToken; + private final AnalyticsHttpTransport transport; + private final ObjectMapper objectMapper; + + @Autowired + public SentryClient( + @Value("${picke.analytics.sentry.base-url:https://sentry.io}") String baseUrl, + @Value("${picke.analytics.sentry.organization:${SENTRY_ORG:picke}}") String organization, + @Value("${picke.analytics.sentry.projects:${SENTRY_PROJECTS:picke-ios,picke-android}}") + List projects, + @Value("${picke.analytics.sentry.auth-token:${SENTRY_AUTH_TOKEN:}}") String authToken, + AnalyticsHttpTransport transport) { + this(baseUrl, organization, projects, authToken, transport, new ObjectMapper()); + } + + SentryClient(String baseUrl, String organization, List projects, String authToken, + AnalyticsHttpTransport transport, ObjectMapper objectMapper) { + this.baseUrl = baseUrl; + this.organization = organization; + this.projects = projects; + this.authToken = authToken; + this.transport = transport; + this.objectMapper = objectMapper; + } + + public SentryIssueReport fetchUnresolvedIssues(LocalDate from, LocalDate to) { + if (!isConfigured()) { + return SentryIssueReport.empty(AnalyticsStatus.NOT_CONFIGURED, from, to); + } + + List fetched = projects.stream() + .map(project -> fetchProject(project, from, to)) + .toList(); + boolean allConnected = fetched.stream() + .allMatch(project -> project.status() == AnalyticsStatus.CONNECTED); + Long total = allConnected + ? fetched.stream().mapToLong(SentryIssueReport.ProjectIssues::totalEvents).sum() + : null; + return new SentryIssueReport( + allConnected ? AnalyticsStatus.CONNECTED : AnalyticsStatus.UNAVAILABLE, + Instant.now(), from, to, total, fetched); + } + + /** 한 프로젝트가 막혀도 다른 프로젝트는 살린다. iOS 가 죽었다고 Android 지표까지 감추지 않는다. */ + private SentryIssueReport.ProjectIssues fetchProject(String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse issueResponse = transport.get(issueUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + AnalyticsHttpResponse statsResponse = transport.get(statsUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + AnalyticsHttpResponse eventResponse = transport.get(eventUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!issueResponse.isSuccess() || !issueResponse.isJson() + || !statsResponse.isSuccess() || !statsResponse.isJson() + || !eventResponse.isSuccess() || !eventResponse.isJson()) { + log.warn("[Sentry] 조회 실패: project={}, issuesStatus={}, statsStatus={}, eventsStatus={}", + project, issueResponse.statusCode(), statsResponse.statusCode(), eventResponse.statusCode()); + return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE); + } + + try { + List issues = parseIssues(issueResponse.body()); + List days = parseDays(statsResponse.body(), from, to); + List recentEvents = parseEvents(eventResponse.body()); + List datasets = new ArrayList<>(DATASETS.stream() + .map(dataset -> fetchDataset(project, dataset, from, to)) + .toList()); + datasets.add(fetchSignUps(project, from, to)); + SentryIssueReport.AnalyticsEventCatalog analyticsEvents = fetchAnalyticsEvents(project, from, to); + SentryIssueReport.MetricCatalog metricCatalog = fetchMetricCatalog(project, from, to); + SentryIssueReport.SessionHealth sessionHealth = fetchSessionHealth(project, from, to); + SentryIssueReport.ResourceCatalog releases = fetchReleases(project); + long unresolvedTotal = issues.stream() + .map(SentryIssueReport.Issue::events) + .filter(events -> events != null) + .mapToLong(Long::longValue) + .sum(); + long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum(); + return new SentryIssueReport.ProjectIssues( + project, AnalyticsStatus.CONNECTED, total, unresolvedTotal, days, issues, recentEvents, + datasets, analyticsEvents, metricCatalog, sessionHealth, releases); + } catch (Exception e) { + log.warn("[Sentry] 핵심 응답 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + return SentryIssueReport.emptyProject(project, AnalyticsStatus.UNAVAILABLE); + } + } + + private boolean isConfigured() { + return StringUtils.hasText(authToken) + && StringUtils.hasText(organization) + && projects != null + && !projects.isEmpty(); + } + + private URI issueUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/projects/{organization}/{project}/issues/") + .queryParam("query", "is:unresolved") + .queryParam("sort", "freq") + .queryParam("limit", ISSUE_LIMIT) + // 절대 기간을 쓰려면 statsPeriod 를 빈 값으로 넘겨야 한다. 생략하면 기본 기간이 적용된다. + .queryParam("statsPeriod", "") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("utc", "true") + .buildAndExpand(organization, project) + .toUri(); + } + + private URI statsUri(String project, LocalDate from, LocalDate to) { + long since = from.atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + long until = to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond(); + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/projects/{organization}/{project}/stats/") + .queryParam("stat", "received") + .queryParam("since", since) + .queryParam("until", until) + .queryParam("resolution", "1d") + .buildAndExpand(organization, project) + .toUri(); + } + + private URI eventUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/projects/{organization}/{project}/events/") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("full", "true") + .buildAndExpand(organization, project) + .toUri(); + } + + private SentryIssueReport.DatasetSeries fetchDataset( + String project, String dataset, LocalDate from, LocalDate to) { + String yAxis = dataset.equals("tracemetrics") ? "sum(value)" : "count()"; + return fetchDataset(project, dataset, dataset, yAxis, null, from, to); + } + + private SentryIssueReport.DatasetSeries fetchSignUps( + String project, LocalDate from, LocalDate to) { + return fetchDataset(project, "sign_up", "tracemetrics", "sum(value)", + "metric.name:\"user.action.count\" event:sign_up", from, to); + } + + private SentryIssueReport.DatasetSeries fetchDataset( + String project, + String responseName, + String sentryDataset, + String yAxis, + String query, + LocalDate from, + LocalDate to) { + AnalyticsHttpResponse response = transport.get( + datasetUri(project, sentryDataset, yAxis, query, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + log.warn("[Sentry] 데이터셋 조회 실패: project={}, dataset={}, status={}", + project, responseName, response.statusCode()); + return new SentryIssueReport.DatasetSeries( + responseName, AnalyticsStatus.UNAVAILABLE, null, List.of()); + } + try { + List days = parseDatasetDays(response.body(), from, to); + long total = days.stream().mapToLong(SentryIssueReport.Day::events).sum(); + return new SentryIssueReport.DatasetSeries(responseName, AnalyticsStatus.CONNECTED, total, days); + } catch (Exception e) { + log.warn("[Sentry] 데이터셋 파싱 실패: project={}, dataset={}, {}", + project, responseName, e.getClass().getSimpleName()); + return new SentryIssueReport.DatasetSeries( + responseName, AnalyticsStatus.UNAVAILABLE, null, List.of()); + } + } + + private URI datasetUri( + String project, + String dataset, + String yAxis, + String query, + LocalDate from, + LocalDate to) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/events-timeseries/") + .queryParam("project", project) + .queryParam("dataset", dataset) + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("interval", 86400) + .queryParam("yAxis", yAxis); + if (StringUtils.hasText(query)) { + builder.queryParam("query", query); + } + return builder.buildAndExpand(organization).toUri(); + } + + private SentryIssueReport.MetricCatalog fetchMetricCatalog(String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse response = transport.get(metricCatalogUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry metric catalog response must be an array."); + } + List> entries = new ArrayList<>(); + for (JsonNode node : root) { + entries.add(objectMapper.convertValue(node, new TypeReference<>() { })); + } + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.CONNECTED, List.copyOf(entries)); + } catch (Exception e) { + log.warn("[Sentry] 메트릭 카탈로그 파싱 실패: project={}, {}", + project, e.getClass().getSimpleName()); + return new SentryIssueReport.MetricCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + } + + private URI metricCatalogUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/trace-items/metrics/") + .queryParam("project", project) + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("sort", "-count") + .queryParam("expand", "context") + .buildAndExpand(organization) + .toUri(); + } + + private SentryIssueReport.AnalyticsEventCatalog fetchAnalyticsEvents( + String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse catalogResponse = transport.get(analyticsEventCatalogUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!catalogResponse.isSuccess() || !catalogResponse.isJson()) { + log.warn("[Sentry] 분석 이벤트 목록 조회 실패: project={}, status={}", + project, catalogResponse.statusCode()); + return unavailableAnalyticsEvents(); + } + + try { + List catalog = parseAnalyticsEventCatalog( + catalogResponse.body()); + if (catalog.isEmpty()) { + return new SentryIssueReport.AnalyticsEventCatalog( + AnalyticsStatus.CONNECTED, 0L, List.of()); + } + + Map> daysByEvent = new LinkedHashMap<>(); + List names = catalog.stream().map(SentryIssueReport.AnalyticsEventSeries::event).toList(); + for (int start = 0; start < names.size(); start += ANALYTICS_EVENT_BATCH_SIZE) { + List batch = names.subList(start, Math.min(start + ANALYTICS_EVENT_BATCH_SIZE, names.size())); + AnalyticsHttpResponse seriesResponse = transport.get( + analyticsEventTimeseriesUri(project, batch, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!seriesResponse.isSuccess() || !seriesResponse.isJson()) { + log.warn("[Sentry] 분석 이벤트 추이 조회 실패: project={}, status={}", + project, seriesResponse.statusCode()); + return unavailableAnalyticsEvents(); + } + daysByEvent.putAll(parseAnalyticsEventDays(seriesResponse.body(), batch, from, to)); + } + + List events = catalog.stream() + .map(series -> new SentryIssueReport.AnalyticsEventSeries( + series.event(), series.total(), series.uniqueUsers(), series.firstSeen(), series.lastSeen(), + daysByEvent.getOrDefault(series.event(), emptyAnalyticsDays(from, to)))) + .toList(); + long total = events.stream().map(SentryIssueReport.AnalyticsEventSeries::total) + .filter(value -> value != null).mapToLong(Long::longValue).sum(); + return new SentryIssueReport.AnalyticsEventCatalog( + AnalyticsStatus.CONNECTED, total, events); + } catch (Exception e) { + log.warn("[Sentry] 분석 이벤트 응답 파싱 실패: project={}, {}", + project, e.getClass().getSimpleName()); + return unavailableAnalyticsEvents(); + } + } + + private URI analyticsEventCatalogUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/events/") + .queryParam("project", project) + .queryParam("dataset", "errors") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("field", ANALYTICS_EVENT_FIELD) + .queryParam("field", "count()") + .queryParam("field", "count_unique(user)") + .queryParam("field", "min(timestamp)") + .queryParam("field", "max(timestamp)") + .queryParam("query", "has:analytics_event") + .queryParam("sort", "-count()") + .queryParam("per_page", 100) + .buildAndExpand(organization) + .toUri(); + } + + private URI analyticsEventTimeseriesUri( + String project, List events, LocalDate from, LocalDate to) { + String query = events.stream() + .map(event -> "analytics_event:\"" + event.replace("\"", "\\\"") + "\"") + .reduce((left, right) -> left + " OR " + right) + .map(value -> events.size() > 1 ? "(" + value + ")" : value) + .orElse("has:analytics_event"); + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/events-timeseries/") + .queryParam("project", project) + .queryParam("dataset", "errors") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .queryParam("interval", 86400) + .queryParam("query", query) + .queryParam("groupBy", ANALYTICS_EVENT_FIELD) + .queryParam("topEvents", events.size()) + .queryParam("sort", "-count()") + .queryParam("excludeOther", 1) + .queryParam("yAxis", "count()") + .queryParam("yAxis", "count_unique(user)") + .buildAndExpand(organization) + .toUri(); + } + + private List parseAnalyticsEventCatalog(String body) throws Exception { + JsonNode data = objectMapper.readTree(body).path("data"); + if (!data.isArray()) { + throw new IllegalArgumentException("Sentry analytics event response must contain data."); + } + List events = new ArrayList<>(); + for (JsonNode row : data) { + String event = analyticsEventName(row); + if (!StringUtils.hasText(event)) { + continue; + } + events.add(new SentryIssueReport.AnalyticsEventSeries( + event, + number(row, "count()"), + number(row, "count_unique(user)"), + instant(row, "min(timestamp)"), + instant(row, "max(timestamp)"), + List.of())); + } + return events.stream() + .sorted(Comparator.comparing(SentryIssueReport.AnalyticsEventSeries::total, + Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + } + + private Map> parseAnalyticsEventDays( + String body, List events, LocalDate from, LocalDate to) throws Exception { + JsonNode timeSeries = objectMapper.readTree(body).path("timeSeries"); + if (!timeSeries.isArray()) { + throw new IllegalArgumentException("Sentry analytics timeseries response must contain timeSeries."); + } + Map> counts = new LinkedHashMap<>(); + Map> users = new LinkedHashMap<>(); + for (JsonNode series : timeSeries) { + String event = analyticsEventNameFromGroup(series.path("groupBy")); + if (!StringUtils.hasText(event) || !events.contains(event)) { + continue; + } + Map> target = "count_unique(user)".equals(text(series, "yAxis")) + ? users : counts; + Map valuesByDate = target.computeIfAbsent(event, ignored -> new LinkedHashMap<>()); + JsonNode values = series.path("values"); + if (!values.isArray()) { + continue; + } + for (JsonNode point : values) { + LocalDate date = Instant.ofEpochMilli(point.path("timestamp").asLong()) + .atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to) && point.path("value").isNumber()) { + valuesByDate.merge(date, point.path("value").asLong(), Long::sum); + } + } + } + + Map> result = new LinkedHashMap<>(); + for (String event : events) { + List days = new ArrayList<>(); + for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { + days.add(new SentryIssueReport.AnalyticsEventDay( + date, + counts.getOrDefault(event, Map.of()).getOrDefault(date, 0L), + users.getOrDefault(event, Map.of()).getOrDefault(date, 0L))); + } + result.put(event, List.copyOf(days)); + } + return result; + } + + private String analyticsEventName(JsonNode row) { + for (String field : List.of(ANALYTICS_EVENT_FIELD, "tag[analytics_event]", "analytics_event")) { + String value = text(row, field); + if (StringUtils.hasText(value)) { + return value; + } + } + return null; + } + + private String analyticsEventNameFromGroup(JsonNode groupBy) { + if (!groupBy.isArray()) { + return null; + } + for (JsonNode group : groupBy) { + String key = text(group, "key"); + if (ANALYTICS_EVENT_FIELD.equals(key) || "tag[analytics_event]".equals(key) + || "analytics_event".equals(key)) { + return text(group, "value"); + } + } + return null; + } + + private List emptyAnalyticsDays(LocalDate from, LocalDate to) { + List days = new ArrayList<>(); + for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) { + days.add(new SentryIssueReport.AnalyticsEventDay(date, 0L, 0L)); + } + return List.copyOf(days); + } + + private SentryIssueReport.AnalyticsEventCatalog unavailableAnalyticsEvents() { + return new SentryIssueReport.AnalyticsEventCatalog( + AnalyticsStatus.UNAVAILABLE, null, List.of()); + } + + private SentryIssueReport.SessionHealth fetchSessionHealth( + String project, LocalDate from, LocalDate to) { + AnalyticsHttpResponse response = transport.get(sessionUri(project, from, to), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.SessionHealth(AnalyticsStatus.UNAVAILABLE, List.of(), Map.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + JsonNode intervals = root.path("intervals"); + JsonNode groups = root.path("groups"); + if (!intervals.isArray() || !groups.isArray()) { + throw new IllegalArgumentException("Sentry session response must contain intervals and groups."); + } + List series = new ArrayList<>(); + for (JsonNode group : groups) { + String status = text(group.path("by"), "session.status"); + Long total = number(group.path("totals"), "sum(session)"); + JsonNode values = group.path("series").path("sum(session)"); + List days = new ArrayList<>(); + for (int index = 0; index < Math.min(intervals.size(), values.size()); index++) { + LocalDate date = Instant.parse(intervals.get(index).asText()).atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to)) { + days.add(new SentryIssueReport.Day(date, values.get(index).asLong())); + } + } + series.add(new SentryIssueReport.SessionSeries(status, total, List.copyOf(days))); + } + Map details = objectMapper.convertValue(root, new TypeReference<>() { }); + return new SentryIssueReport.SessionHealth( + AnalyticsStatus.CONNECTED, List.copyOf(series), details); + } catch (Exception e) { + log.warn("[Sentry] 세션 상태 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + return new SentryIssueReport.SessionHealth(AnalyticsStatus.UNAVAILABLE, List.of(), Map.of()); + } + } + + private URI sessionUri(String project, LocalDate from, LocalDate to) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/sessions/") + .queryParam("project", project) + .queryParam("field", "sum(session)") + .queryParam("groupBy", "session.status") + .queryParam("interval", "1d") + .queryParam("start", from.atStartOfDay()) + .queryParam("end", to.atTime(LocalTime.MAX).withNano(0)) + .buildAndExpand(organization) + .toUri(); + } + + private SentryIssueReport.ResourceCatalog fetchReleases(String project) { + AnalyticsHttpResponse response = transport.get(releaseUri(project), + Map.of("Authorization", "Bearer " + authToken)); + if (!response.isSuccess() || !response.isJson()) { + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry release response must be an array."); + } + List> entries = new ArrayList<>(); + for (JsonNode node : root) { + entries.add(objectMapper.convertValue(node, new TypeReference<>() { })); + } + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.CONNECTED, List.copyOf(entries)); + } catch (Exception e) { + log.warn("[Sentry] 릴리즈 파싱 실패: project={}, {}", project, e.getClass().getSimpleName()); + return new SentryIssueReport.ResourceCatalog(AnalyticsStatus.UNAVAILABLE, List.of()); + } + } + + private URI releaseUri(String project) { + return UriComponentsBuilder.fromUriString(baseUrl) + .path("/api/0/organizations/{organization}/releases/") + .queryParam("project", project) + .queryParam("per_page", 20) + .buildAndExpand(organization) + .toUri(); + } + + private List parseDays(String body, LocalDate from, LocalDate to) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry stats response must be an array."); + } + Map counts = new java.util.HashMap<>(); + for (JsonNode point : root) { + if (!point.isArray() || point.size() < 2 || !point.get(0).isNumber() || !point.get(1).isNumber()) { + throw new IllegalArgumentException("Sentry stats point must contain timestamp and count."); + } + LocalDate date = Instant.ofEpochSecond(point.get(0).asLong()).atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to)) { + counts.merge(date, point.get(1).asLong(), Long::sum); + } + } + List days = new ArrayList<>(); + for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) { + days.add(new SentryIssueReport.Day(cursor, counts.getOrDefault(cursor, 0L))); + } + return days; + } + + private List parseDatasetDays( + String body, LocalDate from, LocalDate to) throws Exception { + JsonNode root = objectMapper.readTree(body); + JsonNode timeSeries = root.path("timeSeries"); + if (!timeSeries.isArray()) { + throw new IllegalArgumentException("Sentry timeseries response must contain timeSeries."); + } + Map counts = new java.util.HashMap<>(); + for (JsonNode series : timeSeries) { + JsonNode values = series.path("values"); + if (!values.isArray()) { + continue; + } + for (JsonNode point : values) { + if (!point.path("timestamp").isNumber() || !point.path("value").isNumber()) { + throw new IllegalArgumentException("Sentry timeseries point must contain timestamp and value."); + } + LocalDate date = Instant.ofEpochMilli(point.path("timestamp").asLong()) + .atZone(ZoneOffset.UTC).toLocalDate(); + if (!date.isBefore(from) && !date.isAfter(to)) { + counts.merge(date, point.path("value").asLong(), Long::sum); + } + } + } + List days = new ArrayList<>(); + for (LocalDate cursor = from; !cursor.isAfter(to); cursor = cursor.plusDays(1)) { + days.add(new SentryIssueReport.Day(cursor, counts.getOrDefault(cursor, 0L))); + } + return days; + } + + private List parseIssues(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry issue response must be an array."); + } + + List parsed = new ArrayList<>(); + for (JsonNode node : root) { + parsed.add(new SentryIssueReport.Issue( + text(node, "id"), + text(node, "title"), + text(node, "culprit"), + text(node, "level"), + number(node, "count"), + number(node, "userCount"), + instant(node, "firstSeen"), + instant(node, "lastSeen"), + text(node, "permalink"))); + } + // sort=freq 는 절대 기간에서 이벤트 수 내림차순을 보장하지 않는다. 응답 순서를 믿지 않고 다시 정렬한다. + return parsed.stream() + .sorted(Comparator.comparing(SentryIssueReport.Issue::events, + Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + } + + private List parseEvents(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + if (!root.isArray()) { + throw new IllegalArgumentException("Sentry event response must be an array."); + } + + List parsed = new ArrayList<>(); + for (JsonNode node : root) { + List tags = new ArrayList<>(); + JsonNode tagNodes = node.path("tags"); + if (tagNodes.isArray()) { + for (JsonNode tag : tagNodes) { + tags.add(new SentryIssueReport.Tag(text(tag, "key"), text(tag, "value"))); + } + } + Map metadata = node.path("metadata").isObject() + ? objectMapper.convertValue(node.path("metadata"), new TypeReference<>() { }) + : Map.of(); + Map details = objectMapper.convertValue(node, new TypeReference<>() { }); + parsed.add(new SentryIssueReport.Event( + text(node, "eventID"), + text(node, "id"), + text(node, "groupID"), + text(node, "projectID"), + text(node, "title"), + text(node, "message"), + text(node, "platform"), + text(node, "event.type"), + text(node, "location"), + text(node, "culprit"), + text(node, "crashFile"), + instant(node, "dateCreated"), + List.copyOf(tags), + metadata, + details)); + if (parsed.size() == RECENT_EVENT_LIMIT) { + break; + } + } + return parsed; + } + + private String text(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + /** Sentry 는 이벤트 수를 문자열로 준다. 숫자로 와도 읽히게 둔다. */ + private Long number(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + return value.isNumber() ? value.asLong() : Long.parseLong(value.asText()); + } + + private Instant instant(JsonNode node, String field) { + String value = text(node, field); + return value == null ? null : Instant.parse(value); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java new file mode 100644 index 00000000..c7394df9 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/analytics/SentryIssueReport.java @@ -0,0 +1,153 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * Sentry 미해결 이슈 상위 목록. iOS·Android 를 프로젝트별로 나눠 담는다. + * + * @param status 프로젝트 전부가 연결됐을 때만 CONNECTED. + * @param totalEvents 전부 연결됐을 때의 프로젝트 합계. 하나라도 못 읽으면 null 이다. + * 일부만 더한 값을 전체 합계처럼 보여주지 않는다. + */ +public record SentryIssueReport( + AnalyticsStatus status, + Instant fetchedAt, + LocalDate from, + LocalDate to, + Long totalEvents, + List projects) { + + /** + * @param project Sentry 프로젝트 슬러그. + * @param totalEvents 프로젝트 stats API가 반환한 선택 기간 전체 수신 이벤트 수. + * @param issues 이벤트 수 내림차순. + */ + public record ProjectIssues( + String project, + AnalyticsStatus status, + Long totalEvents, + Long unresolvedEvents, + List days, + List issues, + List recentEvents, + List datasets, + AnalyticsEventCatalog analyticsEvents, + MetricCatalog metricCatalog, + SessionHealth sessionHealth, + ResourceCatalog releases) { + } + + public record Day(LocalDate date, Long events) { + } + + /** + * @param events 기간 내 이벤트 수. + * @param users 영향받은 사용자 수. Sentry가 주지 않으면 null. + * @param level error, fatal, warning 등. + * @param lastSeen 마지막 발생 시각. + */ + public record Issue( + String id, + String title, + String culprit, + String level, + Long events, + Long users, + Instant firstSeen, + Instant lastSeen, + String permalink) { + } + + /** + * Sentry 프로젝트 오류 이벤트 목록 API가 full=true 에서 주는 진단 필드다. + * details 는 Sentry 응답 전체를 보존해 SDK 컨텍스트가 추가돼도 서버 계약에서 유실되지 않게 한다. + */ + public record Event( + String eventId, + String id, + String groupId, + String projectId, + String title, + String message, + String platform, + String type, + String location, + String culprit, + String crashFile, + Instant dateCreated, + List tags, + Map metadata, + Map details) { + } + + public record Tag(String key, String value) { + } + + /** errors, logs, spans, profile_functions, tracemetrics 데이터셋의 일별 발생량. */ + public record DatasetSeries( + String dataset, + AnalyticsStatus status, + Long total, + List days) { + } + + /** analytics_event 태그로 전송된 사용자 행동 이벤트 집계. */ + public record AnalyticsEventCatalog( + AnalyticsStatus status, + Long totalEvents, + List events) { + } + + public record AnalyticsEventSeries( + String event, + Long total, + Long uniqueUsers, + Instant firstSeen, + Instant lastSeen, + List days) { + } + + public record AnalyticsEventDay( + LocalDate date, + Long count, + Long uniqueUsers) { + } + + /** 앱이 전송한 커스텀 Sentry 메트릭의 이름·타입·단위·건수·마지막 수집 시각 원본. */ + public record MetricCatalog( + AnalyticsStatus status, + List> entries) { + } + + public record SessionHealth( + AnalyticsStatus status, + List series, + Map details) { + } + + public record SessionSeries( + String status, + Long total, + List days) { + } + + public record ResourceCatalog( + AnalyticsStatus status, + List> entries) { + } + + static SentryIssueReport empty(AnalyticsStatus status, LocalDate from, LocalDate to) { + return new SentryIssueReport(status, null, from, to, null, List.of()); + } + + static ProjectIssues emptyProject(String project, AnalyticsStatus status) { + return new ProjectIssues(project, status, null, null, List.of(), List.of(), List.of(), List.of(), + new AnalyticsEventCatalog(status, null, List.of()), + new MetricCatalog(status, List.of()), + new SessionHealth(status, List.of(), Map.of()), + new ResourceCatalog(status, List.of())); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java new file mode 100644 index 00000000..c25483e4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminAdController.java @@ -0,0 +1,115 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.domain.admin.dto.ad.request.AdStatusRequest; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdCreativeResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdStatsResponse; +import com.swyp.picke.domain.admin.service.AdminAdService; +import com.swyp.picke.global.common.response.ApiResponse; +import com.swyp.picke.global.common.response.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 제휴 광고 API", description = "제휴 광고 소재 관리 및 노출/클릭 집계") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/ads") +@PreAuthorize("hasRole('ADMIN')") +public class AdminAdController { + + private final AdminAdService adminAdService; + + @Operation(summary = "광고 소재 목록", description = "매체/지면/상태로 필터링한다. 값을 비우면 전체를 준다.") + @GetMapping + public ApiResponse> findAll( + @RequestParam(required = false) AdNetwork network, + @RequestParam(required = false) AdSlotCode slot, + @RequestParam(required = false) AdStatus status + ) { + return ApiResponse.onSuccess(adminAdService.findAll(network, slot, status)); + } + + @Operation(summary = "광고 소재 등록", description = "각 매체 콘솔에서 발급한 제휴 링크를 그대로 넣는다.") + @PostMapping + public ApiResponse create(@Valid @RequestBody AdCreativeRequest request) { + return ApiResponse.onSuccess(adminAdService.create(request)); + } + + @Operation(summary = "광고 소재 수정", description = "매체 API가 동기화하는 소재는 수정할 수 없다.") + @PutMapping("/{creativeId}") + public ApiResponse update( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId, + @Valid @RequestBody AdCreativeRequest request + ) { + return ApiResponse.onSuccess(adminAdService.update(creativeId, request)); + } + + @Operation(summary = "광고 소재 게재 상태 변경", + description = "동기화 소재도 끌 수 있다. PAUSED 는 동기화가 되돌리지 않는다.") + @PatchMapping("/{creativeId}/status") + public ApiResponse changeStatus( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId, + @Valid @RequestBody AdStatusRequest request + ) { + return ApiResponse.onSuccess(adminAdService.changeStatus(creativeId, request.status())); + } + + @Operation(summary = "광고 소재 삭제", description = "매체 API가 동기화하는 소재는 삭제할 수 없다.") + @DeleteMapping("/{creativeId}") + public ApiResponse delete( + @Parameter(description = "소재 ID", example = "1") + @PathVariable Long creativeId + ) { + adminAdService.delete(creativeId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "소재별 노출/클릭/CTR", + description = "우리 DB 기준 수치다. 제휴사 정산 리포트와 대조하는 용도로 쓴다.") + @GetMapping("/stats") + public ApiResponse> findStats( + @Parameter(description = "집계 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "집계 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminAdService.findStats(from, to)); + } + + @Operation(summary = "광고 클릭 내역", description = "언제 어떤 소재가 눌렸는지 최신순으로 본다.") + @GetMapping("/clicks") + public ApiResponse> findClickLogs( + @Parameter(description = "조회 시작일", example = "2026-09-01") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "조회 종료일(포함)", example = "2026-09-30") + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @Parameter(description = "1부터 시작", example = "1") + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminAdService.findClickLogs(from, to, page, size)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index 327f4815..e24fc754 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,8 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -31,6 +33,14 @@ public class AdminBattleController { private final AdminBattleService adminBattleService; + @Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)") + @PostMapping("/parse") + public ApiResponse parseBattleScript( + @RequestBody @Valid AdminBattleParseRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText())); + } + @Operation(summary = "배틀 생성") @PostMapping public ApiResponse createBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java new file mode 100644 index 00000000..a831bf5d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminDashboardController.java @@ -0,0 +1,83 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardCreditStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardNewUsersResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.admin.service.AdminDashboardService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@Tag(name = "관리자 대시보드 API", description = "가입/활동 지표 등 어드민 대시보드 조회") +@RestController +@RequestMapping("/api/v1/admin/dashboard") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminDashboardController { + + private final AdminDashboardService adminDashboardService; + + @Operation(summary = "오늘 요약 카드 조회", description = "오늘 기준 신규가입/로그인유저/활동유저(DAU) 수와 현재 전체 유저 수를 조회한다.") + @GetMapping("/summary") + public ApiResponse getSummary() { + return ApiResponse.onSuccess(adminDashboardService.getSummary()); + } + + @Operation(summary = "DAU/MAU 추이 조회", description = "granularity=day면 일자별 DAU, month면 그날 기준 최근 30일 롤링 윈도우의 MAU를 반환한다.") + @GetMapping("/dau-mau") + public ApiResponse getDauMauTrend( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "day") String granularity + ) { + return ApiResponse.onSuccess(adminDashboardService.getDauMauTrend(from, to, granularity)); + } + + @Operation(summary = "신규 가입자 추이 조회", description = "granularity=day면 일자별, week면 주별(ISO 8601, 월요일 시작) 신규 가입자 수를 반환한다.") + @GetMapping("/new-users") + public ApiResponse getNewUsersTrend( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "day") String granularity + ) { + return ApiResponse.onSuccess(adminDashboardService.getNewUsersTrend(from, to, granularity)); + } + + @Operation(summary = "배틀 참여율 조회", description = "기간(targetDate 기준) 내 발행된 배틀들의 배틀당 평균 사전투표/사후투표/관점작성/댓글작성 참여율을 반환한다.") + @GetMapping("/battle-stats") + public ApiResponse getBattleStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getBattleStats(from, to)); + } + + @Operation(summary = "출석 체크율 조회", description = "기간 내 일자별 출석 인원, 총 출석 횟수, 평균 출석률, 개근 보너스(7일 연속 출석) 달성 건수를 반환한다.") + @GetMapping("/attendance-stats") + public ApiResponse getAttendanceStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getAttendanceStats(from, to)); + } + + @Operation(summary = "크레딧 지급 현황 조회", description = "기간 내 크레딧 타입별 지급/차감 건수와 총액, 전체 지급/차감 합계를 반환한다.") + @GetMapping("/credit-stats") + public ApiResponse getCreditStats( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + return ApiResponse.onSuccess(adminDashboardService.getCreditStats(from, to)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java index bfb1cd16..b4d3b6bc 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -1,19 +1,27 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeOptionsResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeTargetCountResponse; import com.swyp.picke.domain.admin.service.AdminNotificationService; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -27,6 +35,7 @@ public class AdminNotificationController { private final AdminNotificationService adminNotificationService; + private final NotificationDispatchService notificationDispatchService; @Operation(summary = "공지사항 작성") @PostMapping @@ -36,6 +45,12 @@ public ApiResponse createNotice( return ApiResponse.onSuccess(adminNotificationService.createNotice(request)); } + @Operation(summary = "공지사항 작성 옵션 조회", description = "공지 작성 시 선택 가능한 category 목록을 내려준다.") + @GetMapping("/options") + public ApiResponse getOptions() { + return ApiResponse.onSuccess(adminNotificationService.getOptions()); + } + @Operation(summary = "공지사항 목록 조회") @GetMapping public ApiResponse getNotices( @@ -46,9 +61,46 @@ public ApiResponse getNotices( return ApiResponse.onSuccess(adminNotificationService.getNotices(category, page, size)); } + @Operation(summary = "공지 발송 대상자 수 미리보기", description = "지금 NOTICE/EVENT 공지를 등록하면 몇 대의 디바이스에 발송되는지 미리 조회한다.") + @GetMapping("/target-count") + public ApiResponse getTargetCount() { + return ApiResponse.onSuccess(adminNotificationService.getTargetCount()); + } + @Operation(summary = "공지사항 상세 조회") @GetMapping("/{noticeId}") public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); } + + @Operation(summary = "공지사항 수정", description = "이미 발송된 알림함/푸시는 재발송되지 않으며, 알림함에 남는 텍스트만 갱신된다.") + @PutMapping("/{noticeId}") + public ApiResponse updateNotice( + @PathVariable Long noticeId, + @RequestBody @Valid AdminNoticeUpdateRequest request + ) { + return ApiResponse.onSuccess(adminNotificationService.updateNotice(noticeId, request)); + } + + @Operation(summary = "공지사항 삭제") + @DeleteMapping("/{noticeId}") + public ApiResponse deleteNotice(@PathVariable Long noticeId) { + adminNotificationService.deleteNotice(noticeId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "공지사항 발송 결과 조회", description = "대상 디바이스 수, 성공/실패 건수를 조회한다. 푸시 발송은 비동기로 이뤄지므로 완료 전에는 pending=true로 내려간다.") + @GetMapping("/{noticeId}/delivery-result") + public ApiResponse getDeliveryResult(@PathVariable Long noticeId) { + return ApiResponse.onSuccess(adminNotificationService.getDeliveryResult(noticeId)); + } + + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") + @PostMapping("/test") + public ApiResponse sendTestPush( + @RequestBody @Valid AdminNotificationTestRequest request + ) { + notificationDispatchService.sendTestPush(request.userId(), request.title(), request.body()); + return ApiResponse.onSuccess(null); + } } diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java new file mode 100644 index 00000000..ee2cb230 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java @@ -0,0 +1,88 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; +import com.swyp.picke.domain.admin.service.AdminNotificationScheduleService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 예약 알림 API", description = "매일 정해진 시각에 자동 발송되는 푸시알림 예약 관리") +@RestController +@RequestMapping("/api/v1/admin/notification-schedules") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminNotificationScheduleController { + + private final AdminNotificationScheduleService adminNotificationScheduleService; + + @Operation(summary = "예약 알림 등록") + @PostMapping + public ApiResponse create( + @RequestBody @Valid AdminNotificationScheduleRequest request + ) { + return ApiResponse.onSuccess(adminNotificationScheduleService.create(request)); + } + + @Operation(summary = "예약 알림 목록 조회") + @GetMapping + public ApiResponse getSchedules() { + return ApiResponse.onSuccess(adminNotificationScheduleService.getSchedules()); + } + + @Operation(summary = "예약 알림 상세 조회") + @GetMapping("/{scheduleId}") + public ApiResponse getSchedule(@PathVariable Long scheduleId) { + return ApiResponse.onSuccess(adminNotificationScheduleService.getSchedule(scheduleId)); + } + + @Operation(summary = "예약 알림 수정") + @PutMapping("/{scheduleId}") + public ApiResponse update( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleRequest request + ) { + return ApiResponse.onSuccess(adminNotificationScheduleService.update(scheduleId, request)); + } + + @Operation(summary = "예약 알림 On/Off 전환") + @PatchMapping("/{scheduleId}/toggle") + public ApiResponse toggle( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleToggleRequest request + ) { + return ApiResponse.onSuccess(adminNotificationScheduleService.toggle(scheduleId, request)); + } + + @Operation(summary = "예약 알림 테스트 발송", description = "저장된 예약 알림의 제목/부제목을 특정 유저에게 알림 설정(ON/OFF) 무관하게 즉시 테스트 발송한다.") + @PostMapping("/{scheduleId}/test") + public ApiResponse sendTest( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleTestRequest request + ) { + adminNotificationScheduleService.sendTest(scheduleId, request); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "예약 알림 삭제") + @DeleteMapping("/{scheduleId}") + public ApiResponse delete(@PathVariable Long scheduleId) { + adminNotificationScheduleService.delete(scheduleId); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java new file mode 100644 index 00000000..6019f230 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java @@ -0,0 +1,58 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 철학자 보이스 API", description = "철학자 이름 → Fish Audio 보이스(reference_id) 매핑 관리") +@RestController +@RequestMapping("/api/v1/admin/philosopher-voices") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminPhilosopherVoiceController { + + private final PhilosopherVoiceService philosopherVoiceService; + + @Operation(summary = "철학자 보이스 매핑 목록") + @GetMapping + public ApiResponse> getAll() { + return ApiResponse.onSuccess(philosopherVoiceService.getAll()); + } + + @Operation(summary = "철학자 보이스 매핑 생성") + @PostMapping + public ApiResponse create(@Valid @RequestBody PhilosopherVoiceRequest request) { + return ApiResponse.onSuccess(philosopherVoiceService.create(request)); + } + + @Operation(summary = "철학자 보이스 매핑 수정") + @PatchMapping("/{id}") + public ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody PhilosopherVoiceRequest request + ) { + return ApiResponse.onSuccess(philosopherVoiceService.update(id, request)); + } + + @Operation(summary = "철학자 보이스 매핑 삭제") + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + philosopherVoiceService.delete(id); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java index e190d6a7..4fcf4e53 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPickeController.java @@ -1,52 +1,52 @@ package com.swyp.picke.domain.admin.controller; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +/** + * 관리자 페이지는 별도 Next.js 앱(admin-web)으로 이관되었다. + * 기존 admin 경로(picke.store/api/v1/admin/**)는 새 앱으로 리다이렉트한다. + * OAuth 콜백(?code=...)은 쿼리스트링을 보존해 새 앱 로그인 페이지로 전달한다. + */ @Hidden @Controller @RequestMapping("/api/v1/admin") public class AdminPickeController { - @Value("${oauth.kakao.client-id}") - private String kakaoClientId; + @Value("${admin.web.url:https://picke.up.railway.app}") + private String adminWebUrl; - @Value("${oauth.google.client-id}") - private String googleClientId; - - @Value("${picke.baseUrl}") - private String baseUrl; + private String redirect(String path, HttpServletRequest request) { + String qs = request.getQueryString(); + return "redirect:" + adminWebUrl + path + (qs != null ? "?" + qs : ""); + } @GetMapping({"", "/"}) - public String index() { - return "redirect:/api/v1/admin/login"; + public String index(HttpServletRequest request) { + return redirect("/login", request); } @GetMapping("/login") - public String adminLoginPage(Model model) { - model.addAttribute("kakaoClientId", kakaoClientId); - model.addAttribute("googleClientId", googleClientId); - model.addAttribute("redirectUri", baseUrl + "/api/v1/admin/login"); - - return "admin/admin-login"; + public String adminLoginPage(HttpServletRequest request) { + return redirect("/login", request); } @GetMapping("/picke/list") - public String pickeListPage() { - return "admin/picke-list"; + public String pickeListPage(HttpServletRequest request) { + return redirect("/picke/list", request); } @GetMapping("/picke") - public String pickeCreatePage() { - return "admin/picke-create"; + public String pickeCreatePage(HttpServletRequest request) { + return redirect("/picke/create", request); } @GetMapping("/picke/notice") - public String noticePage() { - return "admin/admin-notice"; + public String noticePage(HttpServletRequest request) { + return redirect("/notice", request); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminTestAccountController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminTestAccountController.java new file mode 100644 index 00000000..786b97d5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminTestAccountController.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.testaccount.request.CreateTestAccountRequest; +import com.swyp.picke.domain.admin.dto.testaccount.response.TestAccountResponse; +import com.swyp.picke.domain.oauth.service.AuthService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 테스트 계정 API", description = "QA/외부 테스터용 로컬 로그인 계정 발급") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/test-accounts") +@PreAuthorize("hasRole('ADMIN')") +public class AdminTestAccountController { + + private final AuthService authService; + + @Operation(summary = "테스트 계정 생성") + @PostMapping + public ApiResponse createTestAccount(@Valid @RequestBody CreateTestAccountRequest request) { + return ApiResponse.onSuccess(authService.createLocalTestAccount(request)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java new file mode 100644 index 00000000..cdd59638 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminUserController.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.service.AdminUserService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@Tag(name = "관리자 유저 API", description = "관리자 페이지에서 유저를 조회하기 위한 API") +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserController { + + private final AdminUserService adminUserService; + + @Operation(summary = "유저 검색", description = "닉네임/유저태그/이메일(소셜 로그인 유저만 해당)로 유저를 검색한다. 로컬 로그인 유저는 닉네임/유저태그로만 검색된다.") + @GetMapping("/search") + public ApiResponse searchUsers( + @RequestParam String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResponse.onSuccess(adminUserService.searchUsers(keyword, page, size)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java new file mode 100644 index 00000000..a7ba14cb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdCreativeRequest.java @@ -0,0 +1,81 @@ +package com.swyp.picke.domain.admin.dto.ad.request; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Schema(description = "제휴 광고 소재 등록/수정 요청") +public record AdCreativeRequest( + + @Schema(description = "매체", example = "COUPANG") + @NotNull(message = "매체는 필수입니다.") + AdNetwork network, + + @Schema(description = "노출 지면", example = "HOME_FEED") + @NotNull(message = "노출 지면은 필수입니다.") + AdSlotCode slot, + + @Schema(description = "배너 주 문구") + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 100, message = "제목은 100자를 초과할 수 없습니다.") + String title, + + @Schema(description = "배너 보조 문구") + @Size(max = 200, message = "보조 문구는 200자를 초과할 수 없습니다.") + String subtitle, + + @Schema(description = "소재 이미지 URL") + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 500, message = "이미지 URL은 500자를 초과할 수 없습니다.") + String imageUrl, + + @Schema(description = "버튼 문구", example = "구매하러 가기") + @NotBlank(message = "버튼 문구는 필수입니다.") + @Size(max = 30, message = "버튼 문구는 30자를 초과할 수 없습니다.") + String ctaText, + + @Schema(description = "각 매체 콘솔에서 발급한 제휴 링크") + @NotBlank(message = "제휴 링크는 필수입니다.") + @Size(max = 1000, message = "제휴 링크는 1000자를 초과할 수 없습니다.") + String landingUrl, + + @Schema(description = "게재 상태", example = "ACTIVE") + @NotNull(message = "게재 상태는 필수입니다.") + AdStatus status, + + @Schema(description = "노출할 OS. 앱 설치형은 OS가 갈리므로 맞춰야 한다.", example = "ALL") + @NotNull(message = "노출 OS는 필수입니다.") + AdTargetOs targetOs, + + @Schema(description = "가중 로테이션 가중치. 클수록 자주 노출된다.", example = "1") + @Positive(message = "가중치는 1 이상이어야 합니다.") + Integer weight, + + @Schema(description = "게재 시작 시각. 비우면 즉시 시작") + LocalDateTime startsAt, + + @Schema(description = "게재 종료 시각. 비우면 무제한") + LocalDateTime endsAt +) { + + /** + * 시작이 종료보다 뒤면 어떤 소재도 노출되지 않는다. + * 저장은 되고 광고만 조용히 안 나가는 상황이라 등록 시점에 막는다. + */ + @AssertTrue(message = "게재 종료 시각은 시작 시각보다 뒤여야 합니다.") + @Schema(hidden = true) + public boolean isPeriodValid() { + if (startsAt == null || endsAt == null) { + return true; + } + return !startsAt.isAfter(endsAt); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java new file mode 100644 index 00000000..95ba2099 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/request/AdStatusRequest.java @@ -0,0 +1,14 @@ +package com.swyp.picke.domain.admin.dto.ad.request; + +import com.swyp.picke.domain.ad.enums.AdStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "광고 소재 게재 상태 변경 요청") +public record AdStatusRequest( + + @Schema(description = "변경할 게재 상태", example = "PAUSED") + @NotNull(message = "게재 상태는 필수입니다.") + AdStatus status +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java new file mode 100644 index 00000000..e9e4bee4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdClickLogResponse.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "광고 클릭 내역 한 건") +public record AdClickLogResponse( + + Long clickId, + + @Schema(description = "소재 코드", example = "a1b2c3d4") + String code, + + @Schema(description = "소재 제목") + String title, + + AdNetwork network, + + AdSlotCode slot, + + @Schema(description = "클릭 시각") + LocalDateTime clickedAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java new file mode 100644 index 00000000..567970c1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdCreativeResponse.java @@ -0,0 +1,50 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "제휴 광고 소재") +public record AdCreativeResponse( + Long id, + String code, + AdNetwork network, + AdSlotCode slot, + String title, + String subtitle, + String imageUrl, + String ctaText, + String landingUrl, + AdStatus status, + AdSource source, + AdTargetOs targetOs, + int weight, + LocalDateTime startsAt, + LocalDateTime endsAt +) { + + public static AdCreativeResponse from(AdCreative creative) { + return new AdCreativeResponse( + creative.getId(), + creative.getCode(), + creative.getNetwork(), + creative.getSlot(), + creative.getTitle(), + creative.getSubtitle(), + creative.getImageUrl(), + creative.getCtaText(), + creative.getLandingUrl(), + creative.getStatus(), + creative.getSource(), + creative.getTargetOs(), + creative.getWeight(), + creative.getStartsAt(), + creative.getEndsAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java new file mode 100644 index 00000000..26e0f912 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/ad/response/AdStatsResponse.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.admin.dto.ad.response; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "소재별 노출/클릭 집계") +public record AdStatsResponse( + Long creativeId, + String code, + AdNetwork network, + AdSlotCode slot, + String title, + + @Schema(description = "기간 내 노출 수") + long impressions, + + @Schema(description = "기간 내 클릭 수") + long clicks, + + @Schema(description = "클릭률(%). 노출이 0이면 0", example = "1.25") + double ctr +) { + + public static AdStatsResponse of(Long creativeId, String code, AdNetwork network, AdSlotCode slot, + String title, long impressions, long clicks) { + double ctr = impressions == 0 ? 0d : Math.round(clicks * 10000d / impressions) / 100d; + return new AdStatsResponse(creativeId, code, network, slot, title, impressions, clicks, ctr); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java new file mode 100644 index 00000000..23c4c208 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardAttendanceStatsResponse.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardAttendanceStatsResponse( + double avgAttendanceRate, + long totalCount, + long streakAchievedCount, + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java new file mode 100644 index 00000000..9ee17372 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardBattleStatsResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +public record AdminDashboardBattleStatsResponse( + long battleCount, + double avgPreVoteRate, + double avgPostVoteRate, + double avgPerspectiveWriteRate, + double avgCommentWriteRate +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java new file mode 100644 index 00000000..19666a5c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditStatsResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardCreditStatsResponse( + long totalGranted, + long totalDeducted, + List byType +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java new file mode 100644 index 00000000..412fabcd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardCreditTypeStatResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import com.swyp.picke.domain.user.enums.CreditType; + +public record AdminDashboardCreditTypeStatResponse( + CreditType creditType, + long count, + long totalAmount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java new file mode 100644 index 00000000..06476906 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardDauMauResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardDauMauResponse( + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java new file mode 100644 index 00000000..3110208a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardNewUsersResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.util.List; + +public record AdminDashboardNewUsersResponse( + long totalCount, + List items +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java new file mode 100644 index 00000000..876615dc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +public record AdminDashboardSummaryResponse( + long newUserCount, + long loginUserCount, + long activeUserCount, + long totalUserCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java new file mode 100644 index 00000000..9ac9df0a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/dashboard/response/AdminDashboardTrendItemResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.dashboard.response; + +import java.time.LocalDate; + +public record AdminDashboardTrendItemResponse( + LocalDate date, + long count +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java new file mode 100644 index 00000000..8def0bbd --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminNoticeUpdateRequest( + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleRequest.java new file mode 100644 index 00000000..afa039f1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleRequest.java @@ -0,0 +1,12 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalTime; + +public record AdminNotificationScheduleRequest( + @NotBlank String title, + @NotBlank String subtitle, + @NotNull LocalTime sendTime, + @NotNull Boolean enabled +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java new file mode 100644 index 00000000..52341bbc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleTestRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleTestRequest( + @NotNull Long userId +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java new file mode 100644 index 00000000..db4b63f5 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleToggleRequest( + @NotNull Boolean enabled +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java new file mode 100644 index 00000000..3800db4e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationTestRequest.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationTestRequest( + @NotNull Long userId, + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java new file mode 100644 index 00000000..49152cad --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeDeliveryResultResponse.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +public record AdminNoticeDeliveryResultResponse( + Long notificationId, + int targetCount, + int successCount, + int failureCount, + boolean pending +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java new file mode 100644 index 00000000..ae906dcc --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeOptionsResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import java.util.List; + +public record AdminNoticeOptionsResponse( + List categories +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java new file mode 100644 index 00000000..b0f3edbe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNoticeTargetCountResponse.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +public record AdminNoticeTargetCountResponse( + int targetCount +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleListResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleListResponse.java new file mode 100644 index 00000000..edcdbb10 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleListResponse.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import java.util.List; + +public record AdminNotificationScheduleListResponse( + List schedules +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleResponse.java new file mode 100644 index 00000000..34f1ff7b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/response/AdminNotificationScheduleResponse.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.admin.dto.notification.response; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +public record AdminNotificationScheduleResponse( + Long id, + String title, + String subtitle, + LocalTime sendTime, + boolean enabled, + LocalDateTime createdAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java new file mode 100644 index 00000000..10be818c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.request; + +import jakarta.validation.constraints.NotBlank; + +public record PhilosopherVoiceRequest( + @NotBlank String name, + @NotBlank String referenceId, + String voiceLabel, + String imageKey, + String note +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java new file mode 100644 index 00000000..e51907e1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.response; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; + +public record PhilosopherVoiceResponse( + Long id, + String name, + String referenceId, + String voiceLabel, + String imageKey, + String note +) { + /** + * @param imageUrl {@code entity.getImageKey()} 를 {@code ResourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, ...)} + * 로 변환한 호출 가능한 경로. raw key 를 그대로 내려주지 않는다(다른 이미지 필드와 동일한 규칙). + */ + public static PhilosopherVoiceResponse from(PhilosopherVoice entity, String imageUrl) { + return new PhilosopherVoiceResponse( + entity.getId(), + entity.getName(), + entity.getReferenceId(), + entity.getVoiceLabel(), + imageUrl, + entity.getNote() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java index 4bf2988b..dca526b1 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java @@ -2,5 +2,6 @@ public record AdminScenarioOptionRequest( String label, + String title, String nextNodeName ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java index 60563860..cf302c82 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.admin.dto.scenario.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record AdminScenarioScriptRequest( String speakerName, SpeakerType speakerType, - String text + String text, + Tone tone ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java index 50bfc1c7..241c10d2 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java @@ -5,5 +5,6 @@ @Builder public record AdminScenarioOptionResponse( String label, + String title, Long nextNodeId ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java index 2fd0f253..91cb3c43 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.dto.scenario.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record AdminScenarioScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text + String text, + Tone tone ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/request/CreateTestAccountRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/request/CreateTestAccountRequest.java new file mode 100644 index 00000000..6f24b258 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/request/CreateTestAccountRequest.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.admin.dto.testaccount.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "테스트 계정 생성 요청 객체") +public class CreateTestAccountRequest { + + @NotBlank + @Schema(description = "테스트 계정 아이디", example = "qa-tester01") + private String username; + + @NotBlank + @Schema(description = "테스트 계정 비밀번호", example = "P@ssw0rd!") + private String password; +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/response/TestAccountResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/response/TestAccountResponse.java new file mode 100644 index 00000000..3532f285 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/testaccount/response/TestAccountResponse.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.testaccount.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TestAccountResponse { + private String username; + private String userTag; +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java new file mode 100644 index 00000000..b0592eef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSearchResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +import java.util.List; + +public record AdminUserSearchResponse( + List items, + boolean hasNext +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java new file mode 100644 index 00000000..7273638a --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/user/response/AdminUserSummaryResponse.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.user.response; + +public record AdminUserSummaryResponse( + Long userId, + String userTag, + String nickname, + String email +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java new file mode 100644 index 00000000..d9884789 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminAdService.java @@ -0,0 +1,206 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import com.swyp.picke.domain.ad.service.AdCreativeCodeGenerator; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.domain.admin.dto.ad.response.AdClickLogResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdCreativeResponse; +import com.swyp.picke.domain.admin.dto.ad.response.AdStatsResponse; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.common.response.PageResponse; +import java.net.URI; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class AdminAdService { + + private final AdCreativeRepository adCreativeRepository; + private final AdClickLogRepository adClickLogRepository; + private final AdImpressionDailyRepository adImpressionDailyRepository; + private final AdCreativeCodeGenerator adCreativeCodeGenerator; + + @Value("${coupang.partners.id:}") + private String coupangPartnersId; + + @Transactional + public AdCreativeResponse create(AdCreativeRequest request) { + validateLandingUrl(request.landingUrl()); + validateCoupangOwnership(request); + + AdCreative creative = AdCreative.builder() + .code(adCreativeCodeGenerator.generate()) + .network(request.network()) + .slot(request.slot()) + .title(request.title()) + .subtitle(request.subtitle()) + .imageUrl(request.imageUrl()) + .ctaText(request.ctaText()) + .landingUrl(request.landingUrl()) + .status(request.status()) + .weight(request.weight()) + .startsAt(request.startsAt()) + .endsAt(request.endsAt()) + .source(AdSource.MANUAL) + .targetOs(request.targetOs()) + .build(); + + return AdCreativeResponse.from(adCreativeRepository.save(creative)); + } + + @Transactional + public AdCreativeResponse update(Long creativeId, AdCreativeRequest request) { + validateLandingUrl(request.landingUrl()); + validateCoupangOwnership(request); + + AdCreative creative = findById(creativeId); + rejectIfManaged(creative); + + creative.update( + request.network(), + request.slot(), + request.title(), + request.subtitle(), + request.imageUrl(), + request.ctaText(), + request.landingUrl(), + request.status(), + request.weight(), + request.startsAt(), + request.endsAt(), + request.targetOs() + ); + + return AdCreativeResponse.from(creative); + } + + @Transactional + public void delete(Long creativeId) { + AdCreative creative = findById(creativeId); + rejectIfManaged(creative); + adCreativeRepository.delete(creative); + } + + /** + * 동기화 소재도 끌 수는 있어야 한다. + * PAUSED 는 동기화가 되돌리지 않으므로 어드민의 끄기 스위치가 된다. + */ + @Transactional + public AdCreativeResponse changeStatus(Long creativeId, AdStatus status) { + AdCreative creative = findById(creativeId); + creative.changeStatus(status); + return AdCreativeResponse.from(creative); + } + + @Transactional(readOnly = true) + public List findAll(AdNetwork network, AdSlotCode slot, AdStatus status) { + return adCreativeRepository.search(network, slot, status).stream() + .map(AdCreativeResponse::from) + .toList(); + } + + /** + * 소재별 노출/클릭/CTR. 우리 DB 기준 수치이므로 제휴사 정산 리포트와 대조하는 용도다. + */ + @Transactional(readOnly = true) + public List findStats(LocalDate from, LocalDate to) { + Map impressions = adImpressionDailyRepository.sumByCreativeBetween(from, to).stream() + .collect(Collectors.toMap( + AdImpressionDailyRepository.CreativeCount::getCreativeId, + AdImpressionDailyRepository.CreativeCount::getTotal)); + + Map clicks = adClickLogRepository + .countByCreativeBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()).stream() + .collect(Collectors.toMap( + AdClickLogRepository.CreativeCount::getCreativeId, + AdClickLogRepository.CreativeCount::getTotal)); + + return adCreativeRepository.findAllByOrderByIdDesc().stream() + .map(creative -> AdStatsResponse.of( + creative.getId(), + creative.getCode(), + creative.getNetwork(), + creative.getSlot(), + creative.getTitle(), + impressions.getOrDefault(creative.getId(), 0L), + clicks.getOrDefault(creative.getId(), 0L))) + .toList(); + } + + /** + * 클릭 내역 목록. 어드민에서 "지금 광고가 실제로 눌리고 있는지"를 바로 확인하는 용도다. + */ + @Transactional(readOnly = true) + public PageResponse findClickLogs(LocalDate from, LocalDate to, int page, int size) { + return PageResponse.of(adClickLogRepository.findClickLogs( + from.atStartOfDay(), + to.plusDays(1).atStartOfDay(), + PageRequest.of(Math.max(0, page - 1), size))); + } + + /** + * 클릭 시점에 {@code AffiliateLinks.merge}가 인코딩이 끝난 URL을 가정하고 파싱한다. + * 등록 때 걸러 두지 않으면 잘못된 URL이 그대로 저장되고, 사용자가 배너를 누르는 순간에야 500이 난다. + * 매체·파트너스 아이디와 무관하게 모든 소재에 적용한다. 애드픽 수동 소재와 단축 링크도 여기를 탄다. + */ + private void validateLandingUrl(String landingUrl) { + try { + URI uri = UriComponentsBuilder.fromUriString(landingUrl).build(true).toUri(); + if (uri.getScheme() == null || uri.getHost() == null) { + throw new CustomException(ErrorCode.AD_INVALID_LANDING_URL); + } + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.AD_INVALID_LANDING_URL); + } + } + + /** + * 남의 파트너스 링크를 잘못 붙여넣으면 우리가 광고를 싣고 수수료는 남이 받는다. + * 다만 link.coupang.com 단축 링크에는 lptag가 드러나지 않으므로, 파라미터가 있을 때만 대조한다. + * 없다고 막으면 정상적인 단축 링크를 쓸 수 없다. + */ + private void validateCoupangOwnership(AdCreativeRequest request) { + if (request.network() != AdNetwork.COUPANG || !StringUtils.hasText(coupangPartnersId)) { + return; + } + + String lptag = UriComponentsBuilder.fromUriString(request.landingUrl()) + .build() + .getQueryParams() + .getFirst("lptag"); + + if (lptag != null && !coupangPartnersId.equals(lptag)) { + throw new CustomException(ErrorCode.AD_COUPANG_PARTNER_MISMATCH); + } + } + + private AdCreative findById(Long creativeId) { + return adCreativeRepository.findById(creativeId) + .orElseThrow(() -> new CustomException(ErrorCode.AD_CREATIVE_NOT_FOUND)); + } + + /** 애드픽 동기화가 내용을 덮어쓰므로 어드민이 고치거나 지워도 다음 회차에 되돌아간다. */ + private void rejectIfManaged(AdCreative creative) { + if (creative.isManaged()) { + throw new CustomException(ErrorCode.AD_CREATIVE_MANAGED); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index a35cbc20..0e082f3b 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -4,6 +4,8 @@ import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.service.BattleScriptParseService; import com.swyp.picke.domain.battle.service.BattleService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,6 +15,11 @@ public class AdminBattleService { private final BattleService battleService; + private final BattleScriptParseService battleScriptParseService; + + public AdminBattleParseResponse parseBattleScript(String rawText) { + return battleScriptParseService.parse(rawText); + } public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId) { return battleService.createBattle(request, adminUserId); diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java new file mode 100644 index 00000000..79fe339e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminDashboardService.java @@ -0,0 +1,158 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardCreditStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardCreditTypeStatResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardNewUsersResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardTrendItemResponse; +import com.swyp.picke.domain.attendance.repository.AttendanceRecordRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.CreditHistoryRepository; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.projection.CreditTypeStats; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService { + + private final UserRepository userRepository; + private final UserDailyActivityRepository userDailyActivityRepository; + private final BattleRepository battleRepository; + private final AttendanceRecordRepository attendanceRecordRepository; + private final CreditHistoryRepository creditHistoryRepository; + + public AdminDashboardSummaryResponse getSummary() { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + long newUserCount = userRepository.countByCreatedAtBetween(todayStart, tomorrowStart); + long loginUserCount = userDailyActivityRepository.countByActivityDateAndLoggedInTrue(today); + long activeUserCount = userDailyActivityRepository.countByActivityDateAndActiveTrue(today); + long totalUserCount = userRepository.countByStatus(UserStatus.ACTIVE); + + return new AdminDashboardSummaryResponse(newUserCount, loginUserCount, activeUserCount, totalUserCount); + } + + public AdminDashboardDauMauResponse getDauMauTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "month".equalsIgnoreCase(granularity) + ? userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to) + : userDailyActivityRepository.findDailyActiveUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + return new AdminDashboardDauMauResponse(items); + } + + public AdminDashboardNewUsersResponse getNewUsersTrend(LocalDate from, LocalDate to, String granularity) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = "week".equalsIgnoreCase(granularity) + ? userRepository.findWeeklyNewUserCounts(from, to) + : userRepository.findDailyNewUserCounts(from, to); + + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + // week 단위 items는 주 경계가 from~to 범위를 벗어날 수 있어(예: to가 주 중간이면 그 주 전체를 포함), + // 요청한 기간 전체의 정확한 합계는 items 합산이 아니라 별도 카운트로 계산한다. + long totalCount = userRepository.countByCreatedAtBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + return new AdminDashboardNewUsersResponse(totalCount, items); + } + + public AdminDashboardBattleStatsResponse getBattleStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + long totalUsers = userRepository.countByStatus(UserStatus.ACTIVE); + BattleParticipationStats stats = battleRepository.findParticipationStats(from, to, totalUsers); + + return new AdminDashboardBattleStatsResponse( + stats.getBattleCount(), + orZero(stats.getAvgPreVoteRate()), + orZero(stats.getAvgPostVoteRate()), + orZero(stats.getAvgPerspectiveRate()), + orZero(stats.getAvgCommentRate()) + ); + } + + public AdminDashboardAttendanceStatsResponse getAttendanceStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = attendanceRecordRepository.findDailyAttendanceCounts(from, to); + List items = rows.stream() + .map(row -> new AdminDashboardTrendItemResponse(row.getActivityDate(), row.getCount())) + .toList(); + + long totalCount = items.stream().mapToLong(AdminDashboardTrendItemResponse::count).sum(); + + long totalUsers = userRepository.countByStatus(UserStatus.ACTIVE); + double avgAttendanceRate = (totalUsers > 0 && !items.isEmpty()) + ? items.stream().mapToDouble(item -> (double) item.count() / totalUsers).average().orElse(0.0) + : 0.0; + + long streakAchievedCount = creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + CreditType.ATTENDANCE_STREAK, from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + return new AdminDashboardAttendanceStatsResponse(avgAttendanceRate, totalCount, streakAchievedCount, items); + } + + public AdminDashboardCreditStatsResponse getCreditStats(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + + List rows = creditHistoryRepository.findStatsByCreatedAtBetween( + from.atStartOfDay(), to.plusDays(1).atStartOfDay()); + + List byType = rows.stream() + .map(row -> new AdminDashboardCreditTypeStatResponse( + row.getCreditType(), row.getCount(), row.getTotalAmount())) + .toList(); + + long totalGranted = byType.stream() + .filter(stat -> stat.totalAmount() > 0) + .mapToLong(AdminDashboardCreditTypeStatResponse::totalAmount) + .sum(); + long totalDeducted = byType.stream() + .filter(stat -> stat.totalAmount() < 0) + .mapToLong(stat -> -stat.totalAmount()) + .sum(); + + return new AdminDashboardCreditStatsResponse(totalGranted, totalDeducted, byType); + } + + private double orZero(Double value) { + return value != null ? value : 0.0; + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java new file mode 100644 index 00000000..770915aa --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java @@ -0,0 +1,89 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; +import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminNotificationScheduleService { + + private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationDispatchService notificationDispatchService; + + @Transactional + public AdminNotificationScheduleResponse create(AdminNotificationScheduleRequest request) { + NotificationSchedule schedule = NotificationSchedule.builder() + .title(request.title()) + .subtitle(request.subtitle()) + .sendTime(request.sendTime()) + .enabled(request.enabled()) + .build(); + + return toResponse(notificationScheduleRepository.save(schedule)); + } + + public AdminNotificationScheduleListResponse getSchedules() { + return new AdminNotificationScheduleListResponse( + notificationScheduleRepository.findAll().stream() + .map(this::toResponse) + .toList() + ); + } + + public AdminNotificationScheduleResponse getSchedule(Long scheduleId) { + return toResponse(getExistingSchedule(scheduleId)); + } + + @Transactional + public AdminNotificationScheduleResponse update(Long scheduleId, AdminNotificationScheduleRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + schedule.update(request.title(), request.subtitle(), request.sendTime(), request.enabled()); + return toResponse(schedule); + } + + @Transactional + public AdminNotificationScheduleResponse toggle(Long scheduleId, AdminNotificationScheduleToggleRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + schedule.changeEnabled(request.enabled()); + return toResponse(schedule); + } + + public void sendTest(Long scheduleId, AdminNotificationScheduleTestRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + notificationDispatchService.sendTestPush(request.userId(), schedule.getTitle(), schedule.getSubtitle()); + } + + @Transactional + public void delete(Long scheduleId) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + notificationScheduleRepository.delete(schedule); + } + + private NotificationSchedule getExistingSchedule(Long scheduleId) { + return notificationScheduleRepository.findById(scheduleId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_SCHEDULE_NOT_FOUND)); + } + + private AdminNotificationScheduleResponse toResponse(NotificationSchedule schedule) { + return new AdminNotificationScheduleResponse( + schedule.getId(), + schedule.getTitle(), + schedule.getSubtitle(), + schedule.getSendTime(), + schedule.isEnabled(), + schedule.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java index c5f66053..d46663c3 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java @@ -1,17 +1,24 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDeliveryResultResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeOptionsResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeSummaryResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeTargetCountResponse; import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.NotificationRepository; import com.swyp.picke.domain.notification.service.NotificationDispatchService; import com.swyp.picke.domain.notification.service.NotificationService; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; @@ -28,6 +35,7 @@ public class AdminNotificationService { private final NotificationService notificationService; private final NotificationDispatchService notificationDispatchService; private final NotificationRepository notificationRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; @Transactional public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) { @@ -41,7 +49,7 @@ public AdminNoticeDetailResponse createNotice(AdminNoticeCreateRequest request) if (detailCode.getCategory() == NotificationCategory.NOTICE || detailCode.getCategory() == NotificationCategory.EVENT) { - notificationDispatchService.notifyAdminNotice(detailCode, request.title(), request.body()); + notificationDispatchService.notifyAdminNotice(notification.getId(), detailCode, request.title(), request.body()); } return toDetailResponse(notification); @@ -66,11 +74,55 @@ public AdminNoticeListResponse getNotices(NotificationCategory category, int pag } public AdminNoticeDetailResponse getNoticeDetail(Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); return toDetailResponse(notification); } + @Transactional + public AdminNoticeDetailResponse updateNotice(Long notificationId, AdminNoticeUpdateRequest request) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.updateContent(request.title(), request.body()); + return toDetailResponse(notification); + } + + @Transactional + public void deleteNotice(Long notificationId) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.delete(); + } + + public AdminNoticeDeliveryResultResponse getDeliveryResult(Long notificationId) { + notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + + NotificationDeliveryResult result = notificationDeliveryResultRepository.findByNotificationId(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_DELIVERY_RESULT_NOT_FOUND)); + + return new AdminNoticeDeliveryResultResponse( + result.getNotificationId(), + result.getTargetCount(), + result.getSuccessCount(), + result.getFailureCount(), + result.isPending() + ); + } + + public AdminNoticeTargetCountResponse getTargetCount() { + return new AdminNoticeTargetCountResponse(notificationDispatchService.countAdminNoticeTargets()); + } + + public AdminNoticeOptionsResponse getOptions() { + List categories = List.of( + NotificationCategory.CONTENT.name(), + NotificationCategory.NOTICE.name(), + NotificationCategory.EVENT.name() + ); + return new AdminNoticeOptionsResponse(categories); + } + private NotificationCategory normalizeCategory(NotificationCategory category) { if (category == null || category == NotificationCategory.ALL) { return null; diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java index 50a62517..f071e4cb 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java @@ -83,7 +83,8 @@ private List toScriptRequests(List sc .map(scriptRequest -> new ScriptRequest( scriptRequest.speakerName(), scriptRequest.speakerType(), - scriptRequest.text() + scriptRequest.text(), + scriptRequest.tone() )) .toList(); } @@ -95,6 +96,7 @@ private List toOptionRequests(List op return optionRequests.stream() .map(optionRequest -> new OptionRequest( optionRequest.label(), + optionRequest.title(), optionRequest.nextNodeName() )) .toList(); diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java new file mode 100644 index 00000000..67c16989 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminUserService.java @@ -0,0 +1,71 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSummaryResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminUserService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final UserRepository userRepository; + private final UserSocialAccountRepository userSocialAccountRepository; + + public AdminUserSearchResponse searchUsers(String keyword, int page, int size) { + int pageNumber = Math.max(0, page); + int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : size; + + // 닉네임/유저태그(로컬+소셜 유저 공통)와 이메일(소셜 로그인 유저만 보유)은 + // 서로 다른 테이블에서 나오는 결과라 각각 조회 후 userId 기준으로 합친다. + Slice byNicknameOrTag = userRepository.searchByNicknameOrUserTag( + keyword, PageRequest.of(pageNumber, pageSize)); + List byEmail = userSocialAccountRepository.findByProviderEmailContaining(keyword); + + Map merged = new LinkedHashMap<>(); + byNicknameOrTag.getContent().forEach(user -> merged.put(user.getId(), user)); + byEmail.forEach(socialAccount -> merged.put(socialAccount.getUser().getId(), socialAccount.getUser())); + + List users = merged.values().stream() + .sorted((a, b) -> Long.compare(b.getId(), a.getId())) + .limit(pageSize) + .toList(); + + Map emailByUserId = emailByUserId(users); + + List items = users.stream() + .map(user -> new AdminUserSummaryResponse( + user.getId(), + user.getUserTag(), + user.getNickname(), + emailByUserId.get(user.getId()) + )) + .toList(); + + boolean hasNext = byNicknameOrTag.hasNext() || merged.size() > pageSize; + + return new AdminUserSearchResponse(items, hasNext); + } + + private Map emailByUserId(List users) { + List userIds = users.stream().map(User::getId).toList(); + Map emailByUserId = new LinkedHashMap<>(); + for (UserSocialAccount socialAccount : userSocialAccountRepository.findByUser_IdIn(userIds)) { + emailByUserId.putIfAbsent(socialAccount.getUser().getId(), socialAccount.getProviderEmail()); + } + return emailByUserId; + } +} diff --git a/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java index 748e5166..c56767e7 100644 --- a/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/com/swyp/picke/domain/attendance/repository/AttendanceRecordRepository.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.attendance.repository; import com.swyp.picke.domain.attendance.entity.AttendanceRecord; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -27,4 +28,16 @@ public interface AttendanceRecordRepository extends JpaRepository countAttendanceGroupByDate(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * 어드민 대시보드용: from~to 구간의 일자별 출석 인원 수. 출석이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(ar.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN attendance_records ar ON ar.attended_date = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyAttendanceCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } diff --git a/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java b/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java index 32f46a55..f2532280 100644 --- a/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java +++ b/src/main/java/com/swyp/picke/domain/attendance/service/AttendanceService.java @@ -36,7 +36,6 @@ public class AttendanceService { private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); - private static final int STREAK_BONUS_THRESHOLD = 7; private static final int STREAK_REWARD_POINTS = CreditType.ATTENDANCE_STREAK.getDefaultAmount(); private final AttendanceRecordRepository attendanceRecordRepository; @@ -54,7 +53,11 @@ public CheckResponse check() { throw new CustomException(ErrorCode.ATTENDANCE_ALREADY_CHECKED); } - int dailyPoints = CreditType.TODAY_CREDIT.getDefaultAmount(); + // 월~토 개근 후 맞는 일요일에는 5P 대신 7P를 지급(대체), 그 외에는 매일 5P + boolean perfectWeekBonus = isSundayWithFullWeekAttendance(user.getId(), today); + CreditType creditType = perfectWeekBonus ? CreditType.ATTENDANCE_STREAK : CreditType.TODAY_CREDIT; + int dailyPoints = creditType.getDefaultAmount(); + AttendanceRecord record = AttendanceRecord.builder() .user(user) .attendedDate(today) @@ -63,19 +66,19 @@ public CheckResponse check() { attendanceRecordRepository.save(record); Long referenceId = toDateLong(today); - creditService.addCredit(user.getId(), CreditType.TODAY_CREDIT, referenceId); + creditService.addCredit(user.getId(), creditType, referenceId); AttendanceStreak streak = getOrCreateStreak(user, today); - boolean streakBonusEarned = updateStreak(streak, today, user.getId()); + updateStreak(streak, today); - int streakBonusPoints = streakBonusEarned ? STREAK_REWARD_POINTS : 0; + int streakBonusPoints = perfectWeekBonus ? dailyPoints : 0; int totalPoints = creditService.getTotalPoints(user.getId()); return new CheckResponse( user.getUserTag(), LocalDateTime.now(SEOUL_ZONE), dailyPoints, - streakBonusEarned, + perfectWeekBonus, streakBonusPoints, streak.getCurrentStreak(), totalPoints @@ -174,10 +177,10 @@ private AttendanceStreak getOrCreateStreak(User user, LocalDate today) { }); } - private boolean updateStreak(AttendanceStreak streak, LocalDate today, Long userId) { + private void updateStreak(AttendanceStreak streak, LocalDate today) { LocalDate yesterday = today.minusDays(1); boolean continuedStreak = attendanceRecordRepository - .existsByUserIdAndAttendedDate(userId, yesterday); + .existsByUserIdAndAttendedDate(streak.getUserId(), yesterday); if (continuedStreak || streak.getCurrentStreak() == 0) { streak.attend(today); @@ -185,17 +188,17 @@ private boolean updateStreak(AttendanceStreak streak, LocalDate today, Long user LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); streak.resetStreak(weekStart); } + } - boolean justAchieved = streak.getCurrentStreak() >= STREAK_BONUS_THRESHOLD - && streak.isStreakAchieved() - && !creditService.existsHistory(userId, CreditType.ATTENDANCE_STREAK, toDateLong(streak.getStreakWeekStart()) + 7L); - - if (justAchieved) { - Long streakReferenceId = toDateLong(streak.getStreakWeekStart()) + 7L; - creditService.addCredit(userId, CreditType.ATTENDANCE_STREAK, streakReferenceId); + private boolean isSundayWithFullWeekAttendance(Long userId, LocalDate today) { + if (today.getDayOfWeek() != DayOfWeek.SUNDAY) { + return false; } - - return justAchieved; + LocalDate monday = today.minusDays(6); + LocalDate saturday = today.minusDays(1); + List monToSatRecords = + attendanceRecordRepository.findByUserIdAndAttendedDateBetween(userId, monday, saturday); + return monToSatRecords.size() == 6; } private Long toDateLong(LocalDate date) { diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java index 83c1a69d..cb0280df 100644 --- a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -8,6 +8,7 @@ import com.swyp.picke.domain.battle.enums.BattleCreatorType; import com.swyp.picke.domain.tag.entity.Tag; import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.user.enums.PhilosopherType; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.enums.VoteSide; @@ -165,6 +166,8 @@ private List toTagResponses(List tags, TagType targetTyp if (tags == null) return List.of(); return tags.stream() .filter(tag -> targetType == null || tag.getType() == targetType) + .filter(tag -> tag.getType() != TagType.PHILOSOPHER + || PhilosopherType.fromLabel(tag.getName()) != null) .map(tag -> new BattleTagResponse(tag.getId(), tag.getName(), tag.getType())) .toList(); } diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java new file mode 100644 index 00000000..b8d3e4a2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.battle.dto.parse; + +import jakarta.validation.constraints.NotBlank; + +public record AdminBattleParseRequest( + @NotBlank String rawText +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java new file mode 100644 index 00000000..0aa5b962 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.battle.dto.parse; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import java.util.List; +import java.util.Map; + +/** + * 붙여넣기 파싱 결과. DB 에 저장하지 않고 미리보기로만 돌려준다. + * + *

{@code scenarioPayload.battleId} 는 null 이다 — 배틀이 아직 없어서다. + * 어드민이 {@code battlePayload} 로 배틀을 먼저 만든 뒤, 받은 id 를 채워 시나리오를 등록한다. + * + *

{@code speakerNames} 는 화자(A/B)별 발화자 철학자 이름. voiceSettings 와 함께 미리보기에 표시한다. + */ +public record AdminBattleParseResponse( + AdminBattleCreateRequest battlePayload, + AdminScenarioCreateRequest scenarioPayload, + Map speakerNames, + List warnings +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java new file mode 100644 index 00000000..a5ff6088 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.battle.dto.parse; + +/** + * 붙여넣기 파싱 중 사람이 미리보기에서 확인/보정해야 하는 지점. + * blocking=true 면 그 상태로는 발행할 수 없다(보이스 미지정 등). + */ +public record ParseWarning( + String code, + String message, + String context, + boolean blocking +) { + public static ParseWarning of(String code, String message, String context) { + return new ParseWarning(code, message, context, false); + } + + public static ParseWarning blocking(String code, String message, String context) { + return new ParseWarning(code, message, context, true); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java new file mode 100644 index 00000000..40f301c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import java.util.ArrayList; +import java.util.List; + +/** + * 붙여넣은 대본을 정규식으로 1차 분해한 중간 표현. 태그 ID·보이스·감정 분류는 아직 안 붙었다. + */ +public class BattleScriptDocument { + + public boolean interactive; + public String title; + public String category; + public OptionMeta optionA = new OptionMeta(); + public OptionMeta optionB = new OptionMeta(); + public final List nodes = new ArrayList<>(); + public final List warnings = new ArrayList<>(); + + public static class OptionMeta { + /** 사전 투표 줄의 짧은 선택지명(예: "유죄다"). 있으면 이게 최종 title 이 된다. */ + public String choiceName; + /** 메타데이터 표 "선택지 명칭" 값. 사전 투표가 없을 때만 title 로 쓰는 폴백. */ + public String metadataChoiceName; + /** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */ + public String primarySpeaker; + public final List philosopherKeywords = new ArrayList<>(); + public final List valueTags = new ArrayList<>(); + } + + public static class ParsedNode { + public String name; + public final List scripts = new ArrayList<>(); + /** 선택의 시간 노드에서만: label(A/B) -> 다음 노드 이름 */ + public final List options = new ArrayList<>(); + + public ParsedNode(String name) { + this.name = name; + } + } + + public static class ParsedScript { + /** 철학자 이름. null 이면 나레이터. */ + public String speaker; + public String text; + + public ParsedScript(String speaker, String text) { + this.speaker = speaker; + this.text = text; + } + } + + public static class ParsedOption { + public String label; // "A" / "B" + public String title; // 선택의 시간 줄의 설명 텍스트 (예: "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!") + public String nextNodeName; // "분기_A" 등 + public String speaker; // 선택의 시간 줄에 표기된 발화자 (예: "칸트") + + public ParsedOption(String label, String title, String nextNodeName, String speaker) { + this.label = label; + this.title = title; + this.nextNodeName = nextNodeName; + this.speaker = speaker; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java new file mode 100644 index 00000000..53467ff8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java @@ -0,0 +1,375 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.OptionMeta; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedOption; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedScript; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 붙여넣은 대본 텍스트를 정규식으로 {@link BattleScriptDocument} 로 분해한다. + * 외부 의존성 없음(태그·보이스·LLM 은 상위 서비스가 붙인다). 포맷 규격: 배틀_발행_시나리오_현행_vs_개선.md 2.6 + */ +@Component +public class BattleScriptDocumentParser { + + private static final Pattern HEADER = + Pattern.compile("^\\s*(?:분기\\s*[OX]\\s*버전\\s*)?\\S+\\s*[—–-]\\s*(.+)$"); + private static final Pattern INTERACTIVE_HINT = Pattern.compile("분기\\s*([OX])"); + private static final Pattern PREVOTE = Pattern.compile( + "[\"'“]?\\s*([^\"'”()]+?)\\s*\\(([^)]+)\\)\\s*[\"'”]?\\s*(?:vs|VS|대)\\s*" + + "[\"'“]?\\s*([^\"'”()]+?)\\s*\\(([^)]+)\\)"); + private static final Pattern SECTION_OPENING = Pattern.compile("^\\[?\\s*오프닝\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_ROUND = Pattern.compile("^\\[?\\s*(\\d+)\\s*라운드\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_CLOSING = Pattern.compile("^\\[?\\s*클로징\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_CHOICE = Pattern.compile(".*선택의\\s*시간.*"); + private static final Pattern OPTION_LINE = Pattern.compile("^\\s*([ABab])\\s*[::]\\s*(.+)$"); + private static final Pattern SECTION_BRANCH = Pattern.compile("^\\[\\s*분기\\s*([AB])\\b[^\\]]*\\]\\s*$"); + private static final Pattern INLINE_BRANCH = Pattern.compile("^\\[\\s*([AB])\\s*선택\\s*시\\s*\\]\\s*(.*)$"); + private static final Pattern BRANCH_CONTAINER = Pattern.compile("^\\[\\s*분기\\s*시나리오[^\\]]*\\]\\s*$"); + private static final Pattern SPEAKER_LINE = Pattern.compile("^([^::]{1,25})[::]\\s*(.+)$"); + private static final Pattern SPEAKER_ANNOTATION = Pattern.compile("\\s*\\([^)]*\\)\\s*$"); + private static final Pattern TRAILING_SPEAKER = Pattern.compile("\\(([^)]+)\\)\\s*$"); + private static final Pattern VALUE_TAG = Pattern.compile("\\[([^\\]]+)\\]"); + private static final Pattern BRANCH_MARKER = Pattern.compile("^\\(?\\s*분기\\s*버전\\s*\\)?$"); + private static final Pattern METADATA_MARKER = Pattern.compile("^\\s*메타데이터\\s*$"); + private static final Pattern PREVOTE_MARKER = Pattern.compile("^\\s*사전\\s*투표\\s*$"); + + public BattleScriptDocument parse(String rawText) { + BattleScriptDocument doc = new BattleScriptDocument(); + List lines = normalizeLines(rawText); + + int headerIdx = indexOfHeader(lines, doc); + int metadataIdx = firstMatch(lines, METADATA_MARKER, headerIdx + 1); + int branchIdx = firstMatch(lines, BRANCH_MARKER, headerIdx + 1); + + parsePreVote(lines, headerIdx, metadataIdx, doc); + + List bodyLines; + if (branchIdx >= 0) { + bodyLines = lines.subList(branchIdx + 1, lines.size()); + } else { + int bodyEnd = metadataIdx >= 0 ? metadataIdx : lines.size(); + bodyLines = lines.subList(headerIdx + 1, bodyEnd); + } + parseBody(bodyLines, doc); + + int metaEnd = branchIdx >= 0 && branchIdx > metadataIdx ? branchIdx : lines.size(); + if (metadataIdx >= 0) { + parseMetadata(lines.subList(metadataIdx + 1, metaEnd), doc); + } else { + doc.warnings.add(ParseWarning.of("MISSING_METADATA", + "메타데이터 섹션을 찾지 못했습니다. 카테고리/선택지/태그를 미리보기에서 직접 입력하세요.", null)); + } + + // 본문에 선택/분기가 있으면 인터랙티브로 승격 + boolean bodyIsInteractive = doc.nodes.stream() + .anyMatch(n -> n.name.equals("선택") || n.name.startsWith("분기_")); + doc.interactive = doc.interactive || bodyIsInteractive; + + // 선택지 명칭: 사전 투표의 짧은 이름이 우선, 없을 때만 메타데이터 표의 "선택지 명칭"으로 채운다 + if (doc.optionA.choiceName == null) { + doc.optionA.choiceName = doc.optionA.metadataChoiceName; + } + if (doc.optionB.choiceName == null) { + doc.optionB.choiceName = doc.optionB.metadataChoiceName; + } + return doc; + } + + /** 각 줄을 trim 하고, 빈 줄(문단 구분)은 하나의 "" 로 남긴다. */ + private List normalizeLines(String rawText) { + List out = new ArrayList<>(); + if (rawText == null) { + return out; + } + for (String line : rawText.replace("\r\n", "\n").replace("\r", "\n").split("\n")) { + String trimmed = line.strip(); + if (trimmed.isEmpty()) { + if (!out.isEmpty() && !out.get(out.size() - 1).isEmpty()) { + out.add(""); + } + } else { + out.add(trimmed); + } + } + return out; + } + + private int indexOfHeader(List lines, BattleScriptDocument doc) { + for (int i = 0; i < lines.size(); i++) { + Matcher m = HEADER.matcher(lines.get(i)); + if (m.matches()) { + Matcher hint = INTERACTIVE_HINT.matcher(lines.get(i)); + doc.interactive = hint.find() && "O".equalsIgnoreCase(hint.group(1)); + doc.title = m.group(1).strip(); + return i; + } + } + doc.interactive = false; + doc.title = "(제목 미상)"; + doc.warnings.add(ParseWarning.of("MISSING_HEADER", + "'제목' 형식 헤더(맨 윗줄, '— 제목')를 찾지 못했습니다. 제목을 확인하세요.", null)); + return -1; + } + + private void parsePreVote(List lines, int headerIdx, int metadataIdx, BattleScriptDocument doc) { + int end = metadataIdx >= 0 ? metadataIdx : lines.size(); + for (int i = Math.max(headerIdx, 0); i < end; i++) { + if (PREVOTE_MARKER.matcher(lines.get(i)).matches() || lines.get(i).contains("사전 투표")) { + for (int j = i; j < Math.min(i + 3, end); j++) { + Matcher m = PREVOTE.matcher(lines.get(j)); + if (m.find()) { + doc.optionA.choiceName = m.group(1).strip(); + doc.optionA.primarySpeaker = m.group(2).strip(); + doc.optionB.choiceName = m.group(3).strip(); + doc.optionB.primarySpeaker = m.group(4).strip(); + return; + } + } + } + } + } + + private int firstMatch(List lines, Pattern pattern, int from) { + for (int i = Math.max(from, 0); i < lines.size(); i++) { + if (pattern.matcher(lines.get(i)).matches()) { + return i; + } + } + return -1; + } + + private void parseBody(List body, BattleScriptDocument doc) { + ParsedNode current = new ParsedNode("오프닝"); + boolean paragraphBreak = false; + + for (String line : body) { + if (line.isEmpty()) { + paragraphBreak = true; + continue; + } + if (PREVOTE_MARKER.matcher(line).matches() || PREVOTE.matcher(line).find() + || BRANCH_CONTAINER.matcher(line).matches()) { + continue; // 사전 투표 / 분기 컨테이너 헤더는 본문에서 무시 + } + + Matcher opening = SECTION_OPENING.matcher(line); + Matcher round = SECTION_ROUND.matcher(line); + Matcher closing = SECTION_CLOSING.matcher(line); + Matcher branch = SECTION_BRANCH.matcher(line); + Matcher inlineBranch = INLINE_BRANCH.matcher(line); + + if (opening.matches()) { + current = pushAndStart(doc, current, "오프닝"); + addDialogueLine(current, opening.group(1).strip(), true); // "[오프닝] 텍스트" 형태 대응 + continue; + } + if (SECTION_CHOICE.matcher(line).matches() && !OPTION_LINE.matcher(line).matches()) { + current = pushAndStart(doc, current, "선택"); + continue; + } + if (round.matches()) { + current = pushAndStart(doc, current, round.group(1) + "라운드"); + // 라운드 뒤 텍스트는 부제이므로 버린다 + continue; + } + if (branch.matches()) { + current = pushAndStart(doc, current, "분기_" + branch.group(1).toUpperCase()); + continue; + } + if (inlineBranch.matches()) { + current = pushAndStart(doc, current, "분기_" + inlineBranch.group(1).toUpperCase()); + addDialogueLine(current, inlineBranch.group(2).strip(), true); + continue; + } + if (closing.matches()) { + current = pushAndStart(doc, current, "클로징"); + addDialogueLine(current, closing.group(1).strip(), true); // "[클로징] 텍스트" 형태 대응 + continue; + } + + if ("선택".equals(current.name)) { + parseChoiceLine(line, current); + paragraphBreak = false; + continue; + } + addDialogueLine(current, line, paragraphBreak); + paragraphBreak = false; + } + pushIfNotEmpty(doc, current); + splitFlatBody(doc); + if (doc.nodes.isEmpty()) { + doc.warnings.add(ParseWarning.of("EMPTY_BODY", "본문에서 대사를 찾지 못했습니다.", null)); + } + } + + private void addDialogueLine(ParsedNode node, String line, boolean paragraphBreak) { + if (line.isEmpty()) { + return; + } + Matcher speaker = SPEAKER_LINE.matcher(line); + if (speaker.matches() && !isSentenceColon(speaker.group(1))) { + String name = SPEAKER_ANNOTATION.matcher(speaker.group(1).strip()).replaceAll("").strip(); + node.scripts.add(new ParsedScript(name, stripQuotes(speaker.group(2).strip()))); + return; + } + // 화자 표기 없는 줄: 문단 구분(빈 줄)이 없었고 직전이 특정 화자의 대사면 그 발언의 이어짐으로 합친다. + if (!paragraphBreak && !node.scripts.isEmpty() + && node.scripts.get(node.scripts.size() - 1).speaker != null) { + ParsedScript last = node.scripts.get(node.scripts.size() - 1); + last.text = (last.text + "\n" + stripQuotes(line)).strip(); + } else { + node.scripts.add(new ParsedScript(null, stripQuotes(line))); + } + } + + private String stripQuotes(String text) { + if (text.length() >= 2) { + char first = text.charAt(0); + char last = text.charAt(text.length() - 1); + if ((first == '"' && last == '"') || (first == '“' && last == '”') || (first == '\'' && last == '\'')) { + return text.substring(1, text.length() - 1).strip(); + } + } + return text; + } + + /** 라운드/분기 헤더 없이 오프닝 하나에 대사가 다 들어간 플랫 본문: 첫 나레이션=오프닝, 마지막 나레이션=클로징, 나머지=라운드. */ + private void splitFlatBody(BattleScriptDocument doc) { + if (doc.nodes.size() != 1 || !doc.nodes.get(0).name.equals("오프닝")) { + return; + } + List all = doc.nodes.get(0).scripts; + boolean hasSpeaker = all.stream().anyMatch(s -> s.speaker != null); + if (!hasSpeaker) { + return; + } + doc.nodes.clear(); + + ParsedNode opening = new ParsedNode("오프닝"); + ParsedNode round = new ParsedNode("라운드"); + ParsedNode closing = new ParsedNode("클로징"); + + int lastSpeaker = -1; + for (int i = 0; i < all.size(); i++) { + if (all.get(i).speaker != null) { + lastSpeaker = i; + } + } + for (int i = 0; i < all.size(); i++) { + ParsedScript s = all.get(i); + if (round.scripts.isEmpty() && s.speaker == null) { + opening.scripts.add(s); + } else if (lastSpeaker >= 0 && i > lastSpeaker && s.speaker == null) { + closing.scripts.add(s); + } else { + round.scripts.add(s); + } + } + pushIfNotEmpty(doc, opening); + pushIfNotEmpty(doc, round); + pushIfNotEmpty(doc, closing); + } + + private void parseChoiceLine(String line, ParsedNode choiceNode) { + Matcher m = OPTION_LINE.matcher(line); + if (!m.matches()) { + choiceNode.scripts.add(new ParsedScript(null, line)); + return; + } + String label = m.group(1).strip().toUpperCase(); + String rest = m.group(2).strip(); + String speaker = null; + Matcher t = TRAILING_SPEAKER.matcher(rest); + if (t.find()) { + speaker = t.group(1).strip(); + } + String title = stripQuotes(SPEAKER_ANNOTATION.matcher(rest).replaceAll("").strip()); + choiceNode.options.add(new ParsedOption(label, title, "분기_" + label, speaker)); + } + + private void parseMetadata(List meta, BattleScriptDocument doc) { + OptionMeta target = null; + for (int i = 0; i < meta.size(); i++) { + String line = meta.get(i); + String next = i + 1 < meta.size() ? meta.get(i + 1) : null; + + if (line.contains("A안")) { + target = doc.optionA; + } else if (line.contains("B안")) { + target = doc.optionB; + } else if (line.startsWith("카테고리")) { + doc.category = valueAfter(line, "카테고리", next); + } else if (line.startsWith("선택지 명칭") && target != null) { + target.metadataChoiceName = valueAfter(line, "선택지 명칭", next); + } else if (line.startsWith("철학자 키워드") && target != null) { + for (String kw : valueAfter(line, "철학자 키워드", next).split("[,、\\s]+")) { + String cleaned = kw.replace("#", "").strip(); + if (!cleaned.isEmpty()) { + target.philosopherKeywords.add(cleaned); + } + } + } else if (line.startsWith("성향 지표") && target != null) { + Matcher vm = VALUE_TAG.matcher(valueAfter(line, "성향 지표", next)); + while (vm.find()) { + target.valueTags.add(vm.group(1).strip()); + } + } + } + if (doc.category == null) { + doc.warnings.add(ParseWarning.of("MISSING_CATEGORY", "메타데이터에서 카테고리를 찾지 못했습니다.", null)); + } + } + + /** "카테고리: 철학" 처럼 같은 줄에 값이 있으면 그 값을, 없으면 다음 줄을 값으로 본다. */ + private String valueAfter(String line, String key, String nextLine) { + String inline = line.substring(line.indexOf(key) + key.length()) + .replaceFirst("^\\s*[::]?\\s*", "") + .replaceAll("\\(.*?\\)", "") + .strip(); + if (!inline.isEmpty()) { + return inline; + } + return nextLine == null ? "" : nextLine.strip(); + } + + private boolean isSentenceColon(String prefix) { + String p = prefix.strip(); + if (p.isBlank() || p.length() > 20) { + return true; + } + if (p.startsWith("[") || p.startsWith("👉") || p.contains("라운드")) { + return true; + } + String withoutAnnotation = SPEAKER_ANNOTATION.matcher(p).replaceAll("").strip(); + return withoutAnnotation.split("\\s+").length > 3; + } + + private ParsedNode pushAndStart(BattleScriptDocument doc, ParsedNode current, String newName) { + pushIfNotEmpty(doc, current); + return new ParsedNode(newName); + } + + private void pushIfNotEmpty(BattleScriptDocument doc, ParsedNode node) { + if (!node.scripts.isEmpty() || !node.options.isEmpty()) { + mergeOrAdd(doc, node); + } + } + + private void mergeOrAdd(BattleScriptDocument doc, ParsedNode node) { + for (ParsedNode existing : doc.nodes) { + if (existing.name.equals(node.name)) { + existing.scripts.addAll(node.scripts); + existing.options.addAll(node.options); + return; + } + } + doc.nodes.add(node); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java new file mode 100644 index 00000000..4815ac57 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 대본 대사에 LLM 으로 감정 톤과 효과 태그를 붙인다. 실패해도 파싱을 막지 않는다(빈 결과 반환). + */ +public interface EmotionClassifier { + + record ScriptLine(int index, String speaker, String text) {} + + record ScriptEmotion(Tone tone, String textWithEffects) {} + + /** index -> 분류 결과. 호출 실패 시 빈 맵. */ + Map classify(String battleTitle, List lines); + + /** 발화자 이름 -> "A" | "B". 선택의 시간에 명시가 없을 때 논지↔선택지명칭 매칭. 실패 시 빈 맵. */ + Map bindSpeakers(String battleTitle, String optionATitle, String optionBTitle, + List speakers); + + /** 오프닝 전문을 배틀 카드용 한 줄 요약으로 압축한다. 실패 시 empty. */ + Optional summarizeOpening(String battleTitle, String openingText); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java new file mode 100644 index 00000000..86f35f4d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java @@ -0,0 +1,168 @@ +package com.swyp.picke.domain.battle.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Chat Completions 로 감정 톤/효과 태그를 한 번에 배치 분류한다. + * {@code GptPerspectiveGenerationService} 와 같은 방식(RestClient + openai.* 설정)을 쓴다. + */ +@Slf4j +@Component +public class OpenAiEmotionClassifier implements EmotionClassifier { + + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 30000; + + private static final String TONE_VALUES = + "NEUTRAL, ANGRY, SAD, EMBARRASSED, EMPHASIS, WHISPERING, SOFT_TONE, BREATHY, EXCITED"; + + private static final String CLASSIFY_SYSTEM = """ + 너는 철학 토론 대본의 대사에 감정 연출을 붙이는 도구다. + 각 줄에 대해: + - tone: 그 줄의 대표 감성 톤 하나. 반드시 다음 중 하나: %s. 담담하면 NEUTRAL. + - textWithEffects: 원문을 유지하되, 필요하면 오디오 효과 태그를 문장 안에 삽입한다. + 효과 태그 예: [sighing] [laughing] [chuckling] [clear throat] [break] [long-break]. + 이미 있는 대괄호 태그는 지우지 마라. 톤 태그([angry] 등)는 textWithEffects 에 넣지 마라. + 반드시 JSON 만 출력: {"results":[{"index":0,"tone":"...","textWithEffects":"..."}]} + """.formatted(TONE_VALUES); + + private static final String BIND_SYSTEM = """ + 철학 토론에서 각 발화자가 A안과 B안 중 어느 쪽을 지지하는지 논지로 판단한다. + 반드시 JSON 만 출력: {"bindings":{"발화자이름":"A" 또는 "B"}} + """; + + private static final String SUMMARY_SYSTEM = """ + 너는 철학 토론 배틀 카드에 들어갈 한 줄 요약을 쓰는 도구다. + 오프닝 전문을 읽고, 배틀 카드에 노출할 흥미로운 한 줄 요약을 40자 이내 한국어로 작성해라. + 원문을 그대로 자르지 말고, 핵심 갈등/질문을 압축해라. + 반드시 JSON 만 출력: {"summary":"..."} + """; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.url}") + private String openaiUrl; + + @Value("${openai.emotion.model:gpt-4o-mini}") + private String model; + + @Override + public Map classify(String battleTitle, List lines) { + if (lines == null || lines.isEmpty()) { + return Map.of(); + } + try { + List> payload = new ArrayList<>(); + for (ScriptLine line : lines) { + payload.add(Map.of( + "index", line.index(), + "speaker", line.speaker() == null ? "나레이터" : line.speaker(), + "text", line.text())); + } + String userPrompt = "배틀 제목: " + battleTitle + "\n대사 목록(JSON):\n" + + objectMapper.writeValueAsString(payload); + + JsonNode root = call(CLASSIFY_SYSTEM, userPrompt); + Map out = new HashMap<>(); + for (JsonNode item : root.path("results")) { + int index = item.path("index").asInt(-1); + if (index < 0) { + continue; + } + Tone tone = Tone.fromNameOrNeutral(item.path("tone").asText(null)); + String text = item.path("textWithEffects").asText(null); + out.put(index, new ScriptEmotion(tone, text)); + } + return out; + } catch (Exception e) { + log.warn("[EmotionClassifier] 감정 분류 실패 - 전부 NEUTRAL 로 진행", e); + return Map.of(); + } + } + + @Override + public Map bindSpeakers(String battleTitle, String optionATitle, String optionBTitle, + List speakers) { + if (speakers == null || speakers.isEmpty()) { + return Map.of(); + } + try { + String userPrompt = "배틀 제목: " + battleTitle + + "\nA안: " + optionATitle + + "\nB안: " + optionBTitle + + "\n발화자: " + String.join(", ", speakers); + + JsonNode root = call(BIND_SYSTEM, userPrompt); + Map out = new HashMap<>(); + JsonNode bindings = root.path("bindings"); + bindings.fieldNames().forEachRemaining(name -> { + String label = bindings.path(name).asText("").trim().toUpperCase(); + if (label.equals("A") || label.equals("B")) { + out.put(name, label); + } + }); + return out; + } catch (Exception e) { + log.warn("[EmotionClassifier] 발화자 바인딩 실패", e); + return Map.of(); + } + } + + @Override + public Optional summarizeOpening(String battleTitle, String openingText) { + if (openingText == null || openingText.isBlank()) { + return Optional.empty(); + } + try { + String userPrompt = "배틀 제목: " + battleTitle + "\n오프닝 전문:\n" + openingText; + JsonNode root = call(SUMMARY_SYSTEM, userPrompt); + String summary = root.path("summary").asText(null); + return (summary == null || summary.isBlank()) ? Optional.empty() : Optional.of(summary.trim()); + } catch (Exception e) { + log.warn("[EmotionClassifier] 오프닝 요약 생성 실패", e); + return Optional.empty(); + } + } + + private JsonNode call(String systemPrompt, String userPrompt) throws Exception { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + + Map requestBody = Map.of( + "model", model, + "response_format", Map.of("type", "json_object"), + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt))); + + Map response = restClient.post() + .uri(openaiUrl) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .body(requestBody) + .retrieve() + .body(Map.class); + + List choices = (List) response.get("choices"); + Map choice = (Map) choices.get(0); + Map message = (Map) choice.get("message"); + return objectMapper.readTree((String) message.get("content")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java b/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java new file mode 100644 index 00000000..f1ee0792 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.scenario.enums.Tone; +import com.swyp.picke.global.infra.tts.service.TtsTextNormalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 감정 큐 하이브리드 규칙(배틀_발행_시나리오_현행_vs_개선.md item 9)의 파서 쪽 처리. + * + *

    + *
  • 줄에 톤 태그({@code [angry]} 등)가 있으면 첫 번째를 {@link Tone} 으로 뽑고 text 에서 톤 태그를 모두 제거한다.
  • + *
  • 여러 톤 태그가 있으면 첫 번째만 쓰고 warning.
  • + *
  • 효과 태그({@code [sighing]} 등)는 그대로 두되 표기를 정규화한다.
  • + *
+ */ +@Component +public class ScriptToneExtractor { + + private static final Pattern BRACKET = Pattern.compile("\\[[^\\]]+\\]"); + + public record Result(Tone plannerTone, String cleanedText, List warnings) {} + + public Result extract(String rawText) { + List warnings = new ArrayList<>(); + String text = rawText == null ? "" : rawText; + + List toneTags = new ArrayList<>(); + Matcher m = BRACKET.matcher(text); + while (m.find()) { + Tone.fromInlineTag(m.group()).ifPresent(toneTags::add); + } + + Tone plannerTone = Tone.NEUTRAL; + if (!toneTags.isEmpty()) { + plannerTone = toneTags.get(0); + if (toneTags.size() > 1) { + warnings.add(ParseWarning.of("MULTIPLE_TONE_TAGS", + "톤 태그가 여러 개라 첫 번째(" + plannerTone + ")만 사용합니다.", rawText)); + } + // 톤 태그는 모두 제거(효과 태그는 남긴다) + StringBuilder sb = new StringBuilder(); + Matcher rm = BRACKET.matcher(text); + int last = 0; + while (rm.find()) { + if (Tone.fromInlineTag(rm.group()).isPresent()) { + sb.append(text, last, rm.start()); + last = rm.end(); + } + } + sb.append(text.substring(last)); + text = sb.toString(); + } + + String cleaned = TtsTextNormalizer.normalizeEffectTags(text).replaceAll("\\s{2,}", " ").trim(); + return new Result(plannerTone, cleaned, warnings); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index 79731fe5..b0d6dc9b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -110,4 +111,35 @@ List findRecommendedBattles( @Param("excludeBattleIds") List excludeBattleIds, Pageable pageable ); + + /** + * 어드민 대시보드용: from~to(targetDate 기준) 구간에 발행된 배틀들의 배틀당 평균 참여율. + * totalUsers를 분모로 사전투표/사후투표/관점작성/댓글작성 참여율을 배틀별로 구한 뒤 평균낸다. + */ + @Query(value = """ + SELECT + COUNT(*) AS battle_count, + AVG(sub.pre_vote_count::decimal / NULLIF(:totalUsers, 0)) AS avg_pre_vote_rate, + AVG(sub.post_vote_count::decimal / NULLIF(:totalUsers, 0)) AS avg_post_vote_rate, + AVG(sub.perspective_count::decimal / NULLIF(:totalUsers, 0)) AS avg_perspective_rate, + AVG(sub.comment_count::decimal / NULLIF(:totalUsers, 0)) AS avg_comment_rate + FROM ( + SELECT + b.id, + (SELECT COUNT(*) FROM votes v WHERE v.battle_id = b.id AND v.pre_vote_option_id IS NOT NULL) AS pre_vote_count, + (SELECT COUNT(*) FROM votes v WHERE v.battle_id = b.id AND v.post_vote_option_id IS NOT NULL) AS post_vote_count, + (SELECT COUNT(DISTINCT p.user_id) FROM perspectives p WHERE p.battle_id = b.id) AS perspective_count, + (SELECT COUNT(DISTINCT pc.user_id) FROM perspective_comments pc + JOIN perspectives p2 ON p2.id = pc.perspective_id + WHERE p2.battle_id = b.id) AS comment_count + FROM battles b + WHERE b.status = 'PUBLISHED' AND b.deleted_at IS NULL + AND b.target_date BETWEEN :from AND :to + ) sub + """, nativeQuery = true) + BattleParticipationStats findParticipationStats( + @Param("from") LocalDate from, + @Param("to") LocalDate to, + @Param("totalUsers") long totalUsers + ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java b/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java new file mode 100644 index 00000000..70d338d6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/projection/BattleParticipationStats.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.battle.repository.projection; + +public interface BattleParticipationStats { + long getBattleCount(); + Double getAvgPreVoteRate(); + Double getAvgPostVoteRate(); + Double getAvgPerspectiveRate(); + Double getAvgCommentRate(); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleAutoSeedService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleAutoSeedService.java index 406c1ba2..97d7b102 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleAutoSeedService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleAutoSeedService.java @@ -6,11 +6,9 @@ import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; import com.swyp.picke.domain.perspective.service.GptPerspectiveGenerationService; import com.swyp.picke.domain.user.entity.User; -import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.repository.UserRepository; -import com.swyp.picke.domain.user.service.CreditService; import com.swyp.picke.domain.user.service.UserBattleService; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; @@ -32,7 +30,6 @@ public class BattleAutoSeedService { private final UserRepository userRepository; private final BattleVoteRepository battleVoteRepository; private final UserBattleService userBattleService; - private final CreditService creditService; private final PerspectiveRepository perspectiveRepository; private final GptPerspectiveGenerationService gptPerspectiveGenerationService; @@ -78,8 +75,6 @@ public void seed(Battle battle, List options, int botCount) { .build(); battleVoteRepository.save(vote); - creditService.addCredit(bot.getId(), CreditType.BATTLE_VOTE, vote.getId()); - userBattleService.upsertStep(bot, battle, UserBattleStep.COMPLETED); battle.addParticipant(); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java index 0cb8ef5b..8c6f8846 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleQueryService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.user.enums.PhilosopherType; import java.util.List; import java.util.Map; @@ -84,6 +85,7 @@ public Optional getTopPhilosopherTagNameFromOptions(List optionIds return optionTags.stream() .filter(bot -> bot.getTag().getType() == TagType.PHILOSOPHER) + .filter(bot -> PhilosopherType.fromLabel(bot.getTag().getName()) != null) .collect(Collectors.groupingBy( bot -> bot.getTag().getName(), Collectors.counting() diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java new file mode 100644 index 00000000..5d48224c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java @@ -0,0 +1,401 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioNodeRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioScriptRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.OptionMeta; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedOption; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedScript; +import com.swyp.picke.domain.battle.parser.BattleScriptDocumentParser; +import com.swyp.picke.domain.battle.parser.EmotionClassifier; +import com.swyp.picke.domain.battle.parser.EmotionClassifier.ScriptEmotion; +import com.swyp.picke.domain.battle.parser.EmotionClassifier.ScriptLine; +import com.swyp.picke.domain.battle.parser.ScriptToneExtractor; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import com.swyp.picke.domain.user.enums.ValueAxis; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 붙여넣은 대본을 파싱해 배틀/시나리오 등록 페이로드로 조립한다. DB 저장은 하지 않는다(미리보기용). + * 규격/규칙: 배틀_발행_시나리오_현행_vs_개선.md 2.4 / 2.5 / 2.6 / item 9. + */ +@Service +@RequiredArgsConstructor +public class BattleScriptParseService { + + /** 문장 종결부호(. ! ?) 뒤 공백 기준으로 자른다. 말줄임표(...) 뒤에도 공백이 없으면 안 끊긴다. */ + private static final Pattern SENTENCE_BOUNDARY = Pattern.compile("(?<=[.!?])\\s+(?=\\S)"); + + private final BattleScriptDocumentParser documentParser; + private final ScriptToneExtractor toneExtractor; + private final EmotionClassifier emotionClassifier; + private final PhilosopherVoiceService philosopherVoiceService; + private final TagRepository tagRepository; + + @Value("${fishaudio.voice-id.narrator:}") + private String narratorVoice; + + @Value("${fishaudio.voice-id.user:}") + private String userVoice; + + public AdminBattleParseResponse parse(String rawText) { + BattleScriptDocument doc = documentParser.parse(rawText); + List warnings = new ArrayList<>(doc.warnings); + + Map categoryTags = tagNameToId(TagType.CATEGORY); + Map philosopherTags = tagNameToId(TagType.PHILOSOPHER); + Map valueTags = tagNameToId(TagType.VALUE); + + List battleTagIds = new ArrayList<>(); + if (doc.category != null) { + Long categoryId = categoryTags.get(doc.category); + if (categoryId != null) { + battleTagIds.add(categoryId); + } else { + warnings.add(ParseWarning.of("UNKNOWN_CATEGORY_TAG", + "'" + doc.category + "' 카테고리 태그가 없습니다.", doc.category)); + } + } + + List optionATagIds = resolveOptionTags(doc.optionA, "A", philosopherTags, valueTags, warnings); + List optionBTagIds = resolveOptionTags(doc.optionB, "B", philosopherTags, valueTags, warnings); + + SpeakerBinding binding = bindSpeakers(doc, warnings); + + String description = allNarration(doc, "오프닝"); + String summary = summarizeOpening(doc.title, description, warnings); + + AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest( + doc.title, + summary, + description, + null, + null, + null, + null, + BattleStatus.PENDING, + battleTagIds, + List.of( + new AdminBattleOptionRequest(nz(doc.optionA.choiceName), null, + binding.speakerA, null, 0, optionATagIds), + new AdminBattleOptionRequest(nz(doc.optionB.choiceName), null, + binding.speakerB, null, 1, optionBTagIds)), + 0); + + List nodes = buildNodes(doc, binding, warnings); + Map voiceSettings = buildVoiceSettings(binding, warnings); + + AdminScenarioCreateRequest scenarioPayload = new AdminScenarioCreateRequest( + null, doc.interactive, com.swyp.picke.domain.scenario.enums.ScenarioStatus.PENDING, + nodes, voiceSettings); + + Map speakerNames = new LinkedHashMap<>(); + speakerNames.put("A", binding.speakerA); + speakerNames.put("B", binding.speakerB); + + return new AdminBattleParseResponse(battlePayload, scenarioPayload, speakerNames, warnings); + } + + // ---- 태그 ---- + + private Map tagNameToId(TagType type) { + Map map = new LinkedHashMap<>(); + for (Tag tag : tagRepository.findAllByTypeAndDeletedAtIsNull(type)) { + map.put(tag.getName(), tag.getId()); + } + return map; + } + + private List resolveOptionTags(OptionMeta option, String label, + Map philosopherTags, Map valueTags, + List warnings) { + List ids = new ArrayList<>(); + for (String keyword : option.philosopherKeywords) { + if (PhilosopherType.fromLabel(keyword) == null) { + warnings.add(ParseWarning.of("UNKNOWN_PHILOSOPHER_TAG", + label + "안 철학자 키워드 '" + keyword + "'는 철학자 유형 10인에 없습니다.", keyword)); + continue; + } + Long id = philosopherTags.get(keyword); + if (id == null) { + warnings.add(ParseWarning.of("MISSING_PHILOSOPHER_TAG", + label + "안 철학자 태그 '" + keyword + "'가 DB에 없습니다.", keyword)); + } else { + ids.add(id); + } + } + for (String value : option.valueTags) { + if (ValueAxis.resolve(value).isEmpty()) { + warnings.add(ParseWarning.of("UNKNOWN_VALUE_TAG", + label + "안 성향 지표 '" + value + "'는 가치관 12개 문자열에 없습니다.", value)); + continue; + } + Long id = valueTags.get(value); + if (id == null) { + warnings.add(ParseWarning.of("MISSING_VALUE_TAG", + label + "안 성향 태그 '" + value + "'가 DB에 없습니다.", value)); + } else { + ids.add(id); + } + } + return ids; + } + + // ---- 발화자 A/B 바인딩 ---- + + private static class SpeakerBinding { + String speakerA; + String speakerB; + Map speakerToType = new LinkedHashMap<>(); + } + + private SpeakerBinding bindSpeakers(BattleScriptDocument doc, List warnings) { + SpeakerBinding binding = new SpeakerBinding(); + + List speakers = new ArrayList<>(new LinkedHashSet<>( + doc.nodes.stream() + .flatMap(n -> n.scripts.stream()) + .map(s -> s.speaker) + .filter(s -> s != null) + .toList())); + + // 1) 선택의 시간에 명시가 있으면 그대로 + Map explicit = new LinkedHashMap<>(); + for (ParsedNode node : doc.nodes) { + for (ParsedOption option : node.options) { + if (option.speaker != null) { + explicit.put(option.speaker.strip(), option.label); + } + } + } + + Map labelMap = explicit; + + // 2) 사전 투표 줄에 대표 발화자가 있으면 사용 + if (labelMap.size() < 2 && doc.optionA.primarySpeaker != null && doc.optionB.primarySpeaker != null) { + labelMap = new LinkedHashMap<>(); + labelMap.put(doc.optionA.primarySpeaker.strip(), "A"); + labelMap.put(doc.optionB.primarySpeaker.strip(), "B"); + } + + // 3) 그래도 부족하면 LLM, 그다음 등장 순서 + if (labelMap.size() < 2 && speakers.size() >= 2) { + Map llm = emotionClassifier.bindSpeakers( + doc.title, nz(doc.optionA.choiceName), nz(doc.optionB.choiceName), speakers); + if (llm.size() >= 2) { + labelMap = llm; + } else { + warnings.add(ParseWarning.of("SPEAKER_BINDING_FALLBACK", + "발화자↔A/B 매칭을 등장 순서로 임시 배정했습니다. 미리보기에서 확인하세요.", String.join(", ", speakers))); + labelMap = new LinkedHashMap<>(); + labelMap.put(speakers.get(0), "A"); + labelMap.put(speakers.get(1), "B"); + } + } + + for (Map.Entry e : labelMap.entrySet()) { + SpeakerType type = "A".equalsIgnoreCase(e.getValue()) ? SpeakerType.A : SpeakerType.B; + binding.speakerToType.put(e.getKey(), type); + if (type == SpeakerType.A) { + binding.speakerA = e.getKey(); + } else { + binding.speakerB = e.getKey(); + } + } + if (binding.speakerA == null || binding.speakerB == null) { + warnings.add(ParseWarning.blocking("INCOMPLETE_SPEAKER_BINDING", + "A/B 발화자를 확정하지 못했습니다. 미리보기에서 지정하세요.", null)); + } + return binding; + } + + // ---- 시나리오 노드 ---- + + private List buildNodes(BattleScriptDocument doc, SpeakerBinding binding, + List warnings) { + List order = doc.nodes.stream().map(n -> n.name).toList(); + boolean hasClosing = order.contains("클로징"); + + // 배치 감정 분류: 모든 대사 줄을 인덱스로 모아 한 번 호출 + List llmInput = new ArrayList<>(); + List indexMap = new ArrayList<>(); // [nodeIdx, scriptIdx] + List extracted = new ArrayList<>(); + int running = 0; + for (int ni = 0; ni < doc.nodes.size(); ni++) { + ParsedNode node = doc.nodes.get(ni); + for (int si = 0; si < node.scripts.size(); si++) { + ParsedScript s = node.scripts.get(si); + ScriptToneExtractor.Result r = toneExtractor.extract(s.text); + extracted.add(r); + warnings.addAll(r.warnings()); + llmInput.add(new ScriptLine(running, s.speaker, r.cleanedText())); + indexMap.add(new int[]{ni, si}); + running++; + } + } + Map emotions = emotionClassifier.classify(doc.title, llmInput); + if (emotions.isEmpty() && !llmInput.isEmpty()) { + warnings.add(ParseWarning.of("LLM_CLASSIFY_FAILED", + "감정 자동분류에 실패했습니다. 톤은 전부 NEUTRAL 입니다. 미리보기에서 지정하세요.", null)); + } + + List result = new ArrayList<>(); + int flat = 0; + for (int ni = 0; ni < doc.nodes.size(); ni++) { + ParsedNode node = doc.nodes.get(ni); + List scripts = new ArrayList<>(); + for (int si = 0; si < node.scripts.size(); si++) { + ParsedScript s = node.scripts.get(si); + ScriptToneExtractor.Result ex = extracted.get(flat); + ScriptEmotion emotion = emotions.get(flat); + flat++; + + Tone tone = ex.plannerTone() != Tone.NEUTRAL + ? ex.plannerTone() + : (emotion != null ? emotion.tone() : Tone.NEUTRAL); + String text = emotion != null && emotion.textWithEffects() != null + ? emotion.textWithEffects() + : ex.cleanedText(); + SpeakerType speakerType = resolveSpeakerType(s.speaker, binding); + String speakerName = s.speaker == null ? "나레이터" : s.speaker; + for (String sentence : splitIntoSentences(text)) { + scripts.add(new AdminScenarioScriptRequest(speakerName, speakerType, sentence, tone)); + } + } + + List options = node.options.stream() + .map(o -> new AdminScenarioOptionRequest(o.label, o.title, o.nextNodeName)) + .toList(); + + String autoNext = resolveAutoNext(order, ni, node, hasClosing); + result.add(new AdminScenarioNodeRequest(node.name, ni == 0, autoNext, scripts, options)); + } + return result; + } + + private String resolveAutoNext(List order, int idx, ParsedNode node, boolean hasClosing) { + if (!node.options.isEmpty()) { + return null; // 선택 노드는 interactiveOptions 로 분기 + } + if (node.name.startsWith("분기_")) { + return hasClosing ? "클로징" : null; + } + for (int j = idx + 1; j < order.size(); j++) { + String next = order.get(j); + if (next.startsWith("분기_")) { + return null; // 다음이 분기 블록이면 이 노드가 선택 직전이 아니어야 정상 — 방어적으로 끊음 + } + return next; + } + return null; + } + + private SpeakerType resolveSpeakerType(String speaker, SpeakerBinding binding) { + if (speaker == null) { + return SpeakerType.NARRATOR; + } + return binding.speakerToType.getOrDefault(speaker.strip(), SpeakerType.A); + } + + // ---- 보이스 ---- + + private Map buildVoiceSettings(SpeakerBinding binding, List warnings) { + Map voices = new LinkedHashMap<>(); + putVoice(voices, SpeakerType.A, binding.speakerA, warnings); + putVoice(voices, SpeakerType.B, binding.speakerB, warnings); + if (narratorVoice != null && !narratorVoice.isBlank()) { + voices.put(SpeakerType.NARRATOR, narratorVoice); + } + if (userVoice != null && !userVoice.isBlank()) { + voices.put(SpeakerType.USER, userVoice); + } + return voices; + } + + private void putVoice(Map voices, SpeakerType type, String speaker, + List warnings) { + if (speaker == null) { + return; + } + Optional refId = philosopherVoiceService.findReferenceIdByName(speaker); + if (refId.isPresent()) { + voices.put(type, refId.get()); + } else { + warnings.add(ParseWarning.blocking("MISSING_VOICE", + "'" + speaker + "' 보이스가 philosopher_voice 에 없습니다. 미리보기에서 지정하거나 테이블에 추가하세요.", speaker)); + } + } + + // ---- 유틸 ---- + + /** 오프닝 전문을 LLM으로 한 줄 요약한다(배틀 카드 summary 용). 실패하면 오프닝 원문으로 대신한다. */ + private String summarizeOpening(String title, String openingText, List warnings) { + if (openingText == null || openingText.isBlank()) { + return null; + } + Optional summarized = emotionClassifier.summarizeOpening(title, openingText); + if (summarized.isPresent() && !summarized.get().isBlank()) { + return summarized.get(); + } + warnings.add(ParseWarning.of("SUMMARY_GENERATION_FAILED", + "오프닝 한 줄 요약 생성에 실패해 오프닝 원문을 그대로 넣었습니다. 미리보기에서 다듬어주세요.", null)); + return openingText; + } + + /** 오프닝 노드의 나레이션 문단을 전부 이어붙인다(배틀 description 용). */ + private String allNarration(BattleScriptDocument doc, String nodeName) { + String joined = doc.nodes.stream() + .filter(n -> n.name.equals(nodeName)) + .flatMap(n -> n.scripts.stream()) + .filter(s -> s.speaker == null) + .map(s -> s.text) + .reduce((a, b) -> a + " " + b) + .orElse(null); + return joined; + } + + /** + * 한 발언(text)을 문장 종결부호(. ! ?) 단위로 잘라 각각 별도 Script 로 만들기 위한 목록을 만든다. + * 오디오 파이프라인이 스크립트마다 무음(600ms)을 넣으므로, 문장 단위로 쪼개면 자연스러운 끊어읽기가 된다. + */ + private List splitIntoSentences(String text) { + if (text == null || text.isBlank()) { + return List.of(text == null ? "" : text); + } + List sentences = new ArrayList<>(); + for (String piece : SENTENCE_BOUNDARY.split(text.strip())) { + String trimmed = piece.strip(); + if (!trimmed.isEmpty()) { + sentences.add(trimmed); + } + } + return sentences.isEmpty() ? List.of(text.strip()) : sentences; + } + + private String nz(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 4fb08eee..38dd2757 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -15,6 +15,7 @@ import com.swyp.picke.domain.battle.util.BattleOptionDisplay; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.PhilosopherType; import com.swyp.picke.domain.battle.repository.BattleOptionRepository; import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; import com.swyp.picke.domain.battle.repository.BattleRepository; @@ -649,6 +650,8 @@ private void saveBattleOptionTags(BattleOption option, List tagIds) { tagRepository.findAllById(tagIds).stream() .filter(tag -> tag.getDeletedAt() == null) .filter(tag -> tag.getType() == TagType.PHILOSOPHER || tag.getType() == TagType.VALUE) + .filter(tag -> tag.getType() != TagType.PHILOSOPHER + || PhilosopherType.fromLabel(tag.getName()) != null) .forEach(tag -> battleOptionTagRepository.save( BattleOptionTag.builder().battleOption(option).tag(tag).build())); } @@ -659,6 +662,7 @@ private void replaceBattleOptionTags(BattleOption option, List tagIds) { List existingTags = battleOptionTagRepository.findByBattleOption(option); if (!existingTags.isEmpty()) { battleOptionTagRepository.deleteAll(existingTags); + battleOptionTagRepository.flush(); } saveBattleOptionTags(option, tagIds.stream().distinct().toList()); @@ -676,11 +680,30 @@ private String resolveStoredImageKey(String rawReference, BattleStatus targetSta return null; } if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) { - return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService); + // 실제 S3 업로드/로컬 draft 삭제는 커밋 이후로 미룬다. + // 여기서 바로 승격시키면, 이후 같은 트랜잭션에서 DB 저장이 실패해 롤백돼도 + // 이미 올라간 S3 파일과 지워진 draft는 되돌릴 수 없어 orphan이 된다. + String s3Key = localDraftFileStorageService.resolveS3Key(normalized, fallbackCategory); + promoteDraftAfterCommit(normalized, s3Key); + return s3Key; } return normalized; } + private void promoteDraftAfterCommit(String localReference, String s3Key) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + }); + return; + } + + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) { if (rawReference == null || rawReference.isBlank()) { return null; diff --git a/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java index 2cb20dfa..c176a171 100644 --- a/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationController.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse; import com.swyp.picke.domain.notification.enums.NotificationCategory; import com.swyp.picke.domain.notification.service.NotificationService; import com.swyp.picke.global.common.response.ApiResponse; @@ -35,6 +36,15 @@ public ApiResponse getNotifications( return ApiResponse.onSuccess(notificationService.getNotifications(userId, category, page, size)); } + @Operation(summary = "알림 미읽음 여부 확인 (배지용)") + @GetMapping("/unread") + public ApiResponse hasUnread( + @AuthenticationPrincipal Long userId, + @RequestParam(required = false) NotificationCategory category + ) { + return ApiResponse.onSuccess(notificationService.hasUnread(userId, category)); + } + @Operation(summary = "알림 상세 조회") @GetMapping("/{notificationId}") public ApiResponse getNotificationDetail( @@ -56,8 +66,8 @@ public ApiResponse markAsRead( @Operation(summary = "알림 전체 읽음 처리") @PatchMapping("/read-all") - public ApiResponse markAllAsRead(@AuthenticationPrincipal Long userId) { + public ApiResponse markAllAsRead(@AuthenticationPrincipal Long userId) { notificationService.markAllAsRead(userId); - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(notificationService.hasUnread(userId, NotificationCategory.ALL)); } } diff --git a/src/main/java/com/swyp/picke/domain/notification/controller/NotificationTestController.java b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationTestController.java new file mode 100644 index 00000000..78f2ed77 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/controller/NotificationTestController.java @@ -0,0 +1,33 @@ +package com.swyp.picke.domain.notification.controller; + +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "[Test] Notification", description = "인앱 알림 테스트 API") +@RestController +@RequestMapping("/api/test/notifications") +@RequiredArgsConstructor +public class NotificationTestController { + + private final NotificationService notificationService; + + @Operation(summary = "인앱 알림 즉시 생성", description = "실제 푸시 발송 없이 지정한 유저에게 인앱 알림을 즉시 생성합니다.") + @PostMapping + public ApiResponse createTestNotification( + @RequestParam Long userId, + @RequestParam NotificationDetailCode detailCode, + @RequestParam String body, + @RequestParam Long referenceId + ) { + notificationService.createNotification(userId, detailCode, body, referenceId); + return ApiResponse.onSuccess("userId=" + userId + " 알림 생성 완료 (detailCode=" + detailCode + ")"); + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java new file mode 100644 index 00000000..4b6938b0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/dto/response/NotificationUnreadResponse.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.notification.dto.response; + +public record NotificationUnreadResponse( + boolean hasUnread +) {} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java index e3601400..7a773de2 100644 --- a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java +++ b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java @@ -55,6 +55,9 @@ public class Notification extends BaseEntity { @Column(name = "read_at") private LocalDateTime readAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder private Notification(User user, NotificationCategory category, NotificationDetailCode detailCode, String title, String body, Long referenceId, Long perspectiveId) { @@ -68,10 +71,19 @@ private Notification(User user, NotificationCategory category, NotificationDetai this.read = false; } + public void updateContent(String title, String body) { + this.title = title; + this.body = body; + } + public void markAsRead() { if (!this.read) { this.read = true; this.readAt = LocalDateTime.now(); } } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java new file mode 100644 index 00000000..61294608 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationDeliveryResult.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_delivery_results") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeliveryResult extends BaseEntity { + + @Column(name = "notification_id", nullable = false, unique = true) + private Long notificationId; + + @Column(name = "target_count", nullable = false) + private int targetCount; + + @Column(name = "success_count", nullable = false) + private int successCount; + + @Column(name = "failure_count", nullable = false) + private int failureCount; + + @Builder + private NotificationDeliveryResult(Long notificationId, int targetCount) { + this.notificationId = notificationId; + this.targetCount = targetCount; + this.successCount = 0; + this.failureCount = 0; + } + + public boolean isPending() { + return successCount + failureCount < targetCount; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java new file mode 100644 index 00000000..d17b69d6 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java @@ -0,0 +1,64 @@ +package com.swyp.picke.domain.notification.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "notification_schedules") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationSchedule extends BaseEntity { + + @Column(nullable = false, length = 150) + private String title; + + @Column(nullable = false, length = 150) + private String subtitle; + + @Column(name = "send_time", nullable = false) + private LocalTime sendTime; + + @Column(nullable = false) + private boolean enabled; + + @Column(name = "last_sent_date") + private LocalDate lastSentDate; + + @Builder + private NotificationSchedule(String title, String subtitle, LocalTime sendTime, boolean enabled) { + this.title = title; + this.subtitle = subtitle; + this.sendTime = sendTime; + this.enabled = enabled; + } + + public void update(String title, String subtitle, LocalTime sendTime, boolean enabled) { + this.title = title; + this.subtitle = subtitle; + this.sendTime = sendTime; + this.enabled = enabled; + } + + public void changeEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isDue(LocalTime now, LocalDate today) { + return enabled + && sendTime.getHour() == now.getHour() + && sendTime.getMinute() == now.getMinute() + && !today.equals(lastSentDate); + } + + public void markSent(LocalDate today) { + this.lastSentDate = today; + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java index 8772897a..c27a7d14 100644 --- a/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java +++ b/src/main/java/com/swyp/picke/domain/notification/enums/NotificationDetailCode.java @@ -18,7 +18,10 @@ public enum NotificationDetailCode { POLICY_CHANGE(4, NotificationCategory.NOTICE, "공지사항"), // EVENT (5) - PROMOTION(5, NotificationCategory.EVENT, "이벤트"); + PROMOTION(5, NotificationCategory.EVENT, "이벤트"), + + // NOTICE (8) + DAILY_MESSAGE(8, NotificationCategory.NOTICE, "오늘의 알림"); private final int code; private final NotificationCategory category; diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java new file mode 100644 index 00000000..f538f9ea --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationDeliveryResultRepository.java @@ -0,0 +1,25 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationDeliveryResultRepository extends JpaRepository { + + Optional findByNotificationId(Long notificationId); + + @Modifying + @Query(""" + update NotificationDeliveryResult r + set r.successCount = :successCount, r.failureCount = :failureCount + where r.notificationId = :notificationId + """) + void updateResult( + @Param("notificationId") Long notificationId, + @Param("successCount") int successCount, + @Param("failureCount") int failureCount + ); +} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 7ab6759e..129bd0d5 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,12 +12,15 @@ public interface NotificationRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + @Query(""" SELECT n FROM Notification n WHERE ( (n.user IS NOT NULL AND n.user.id = :userId) OR n.user IS NULL ) + AND n.deletedAt IS NULL AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) @@ -30,6 +34,7 @@ Slice findVisibleNotifications( SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END FROM Notification n WHERE n.user IS NULL + AND n.deletedAt IS NULL AND n.category = :category AND NOT EXISTS ( SELECT 1 FROM NotificationRead nr @@ -38,6 +43,21 @@ AND NOT EXISTS ( """) boolean hasUnreadBroadcast(@Param("userId") Long userId, @Param("category") NotificationCategory category); + @Query(""" + SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END + FROM Notification n + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) + AND ( + (n.user.id = :userId AND n.read = false) + OR (n.user IS NULL AND NOT EXISTS ( + SELECT 1 FROM NotificationRead nr + WHERE nr.notification = n AND nr.userId = :userId + )) + ) + """) + boolean existsUnread(@Param("userId") Long userId, @Param("category") NotificationCategory category); + @Modifying @Query(""" UPDATE Notification n SET n.read = true, n.readAt = CURRENT_TIMESTAMP @@ -47,7 +67,8 @@ AND NOT EXISTS ( @Query(""" SELECT n FROM Notification n - WHERE (:category IS NULL OR n.category = :category) + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) Slice findNotificationsForAdmin( diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationScheduleRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationScheduleRepository.java new file mode 100644 index 00000000..2eddabca --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationScheduleRepository.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.notification.repository; + +import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationScheduleRepository extends JpaRepository { + + List findAllByEnabledTrue(); +} diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java index e6f3869a..f377bc43 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/UserDeviceRepository.java @@ -15,6 +15,8 @@ public interface UserDeviceRepository extends JpaRepository { List findAllByUserIdIn(List userIds); + long countByUserIdIn(List userIds); + List findAllByUserIdAndPlatform(Long userId, DevicePlatform platform); void deleteByUserIdAndFcmToken(Long userId, String fcmToken); diff --git a/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java new file mode 100644 index 00000000..9fb70af2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcher.java @@ -0,0 +1,52 @@ +package com.swyp.picke.domain.notification.scheduler; + +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.notification.service.NotificationService; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 매분 활성화된 예약 알림을 조회해 현재 시각(분 단위)과 일치하면 전체 사용자에게 푸시를 발송한다. + * 같은 예약이 하루에 두 번 발송되지 않도록 {@link NotificationSchedule#markSent}로 발송일을 기록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduleDispatcher { + + private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationService notificationService; + private final NotificationDispatchService notificationDispatchService; + private final Clock clock; + + @Scheduled(cron = "0 * * * * *", zone = "Asia/Seoul") + @Transactional + public void dispatchDueSchedules() { + LocalTime now = LocalTime.now(clock); + LocalDate today = LocalDate.now(clock); + + List dueSchedules = notificationScheduleRepository.findAllByEnabledTrue().stream() + .filter(schedule -> schedule.isDue(now, today)) + .toList(); + + for (NotificationSchedule schedule : dueSchedules) { + Notification notification = notificationService.createBroadcastNotification( + NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle(), null); + notificationDispatchService.notifyAdminNotice( + notification.getId(), NotificationDetailCode.DAILY_MESSAGE, schedule.getTitle(), schedule.getSubtitle()); + schedule.markSent(today); + log.info("[NotificationScheduleDispatcher] sent scheduleId={}, title={}", schedule.getId(), schedule.getTitle()); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java index 78c1f367..eab61335 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationDispatchService.java @@ -1,8 +1,10 @@ package com.swyp.picke.domain.notification.service; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; import com.swyp.picke.domain.notification.entity.UserDevice; import com.swyp.picke.domain.notification.enums.DevicePlatform; import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; import com.swyp.picke.domain.notification.repository.UserDeviceRepository; import com.swyp.picke.domain.user.entity.UserSettings; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -10,6 +12,7 @@ import com.swyp.picke.global.infra.fcm.service.FcmPushService; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,6 +30,7 @@ public class NotificationDispatchService { private final NotificationService notificationService; private final UserDeviceRepository userDeviceRepository; private final UserSettingsRepository userSettingsRepository; + private final NotificationDeliveryResultRepository notificationDeliveryResultRepository; private final FcmPushService fcmPushService; private final ApnsPushService apnsPushService; @@ -83,13 +87,51 @@ public void notifyNewComment(Long perspectiveAuthorId, Long perspectiveId, Long } /** - * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송한다. + * 관리자가 등록한 공지/이벤트 알림에 대해 '이벤트 및 소식 알림' 설정이 ON인 사용자에게 푸시를 발송하고, + * 발송 결과(성공/실패 건수)를 {@link NotificationDeliveryResult}에 기록한다. */ - public void notifyAdminNotice(NotificationDetailCode detailCode, String title, String body) { + public void notifyAdminNotice(Long notificationId, NotificationDetailCode detailCode, String title, String body) { Map data = Map.of("type", detailCode.getCategory().name()); List userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue(); - for (UserDevice device : userDeviceRepository.findAllByUserIdIn(userIds)) { + List devices = userDeviceRepository.findAllByUserIdIn(userIds); + + notificationDeliveryResultRepository.save( + NotificationDeliveryResult.builder() + .notificationId(notificationId) + .targetCount(devices.size()) + .build() + ); + + List> results = devices.stream() + .map(device -> sendPush(device, title, body, data)) + .toList(); + + CompletableFuture.allOf(results.toArray(new CompletableFuture[0])) + .whenComplete((ignored, throwable) -> { + long successCount = results.stream().filter(CompletableFuture::join).count(); + long failureCount = results.size() - successCount; + notificationDeliveryResultRepository.updateResult( + notificationId, (int) successCount, (int) failureCount); + }); + } + + /** + * 관리자가 공지/이벤트를 등록하기 전, 지금 등록하면 몇 명(디바이스 기준)에게 발송될지 미리 조회한다. + * {@link #notifyAdminNotice}와 동일한 대상자 산정 기준('이벤트 및 소식 알림' 설정 ON)을 사용한다. + */ + public int countAdminNoticeTargets() { + List userIds = userSettingsRepository.findUserIdsByMarketingEventEnabledTrue(); + return (int) userDeviceRepository.countByUserIdIn(userIds); + } + + /** + * 관리자가 특정 유저에게 알림 설정(ON/OFF) 체크 없이 즉시 테스트 푸시를 발송한다. + */ + public void sendTestPush(Long userId, String title, String body) { + Map data = Map.of("type", "TEST"); + + for (UserDevice device : userDeviceRepository.findAllByUserId(userId)) { sendPush(device, title, body, data); } } @@ -117,11 +159,10 @@ private void sendCommentPush(Long userId, NotificationDetailCode detailCode, Str } } - private void sendPush(UserDevice device, String title, String body, Map data) { + private CompletableFuture sendPush(UserDevice device, String title, String body, Map data) { if (device.getPlatform() == DevicePlatform.IOS) { - apnsPushService.send(device, title, body, data); - } else { - fcmPushService.send(device, title, body, data); + return apnsPushService.send(device, title, body, data); } + return fcmPushService.send(device, title, body, data); } } diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index 1d5be4b5..50550330 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.notification.dto.response.NotificationDetailResponse; import com.swyp.picke.domain.notification.dto.response.NotificationListResponse; import com.swyp.picke.domain.notification.dto.response.NotificationSummaryResponse; +import com.swyp.picke.domain.notification.dto.response.NotificationUnreadResponse; import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.entity.NotificationRead; import com.swyp.picke.domain.notification.enums.NotificationCategory; @@ -146,8 +147,13 @@ public boolean hasNewBroadcast(Long userId, NotificationCategory category) { return notificationRepository.hasUnreadBroadcast(userId, category); } + public NotificationUnreadResponse hasUnread(Long userId, NotificationCategory category) { + NotificationCategory filterCategory = (category == null || category == NotificationCategory.ALL) ? null : category; + return new NotificationUnreadResponse(notificationRepository.existsUnread(userId, filterCategory)); + } + private Notification getAccessibleNotification(Long userId, Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); boolean isAccessible = notification.getUser() == null diff --git a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java index 0ac93e0a..8946f324 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java +++ b/src/main/java/com/swyp/picke/domain/oauth/controller/AuthController.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.oauth.controller; +import com.swyp.picke.domain.oauth.dto.LocalLoginRequest; import com.swyp.picke.domain.oauth.dto.LoginRequest; import com.swyp.picke.domain.oauth.dto.LoginResponse; import com.swyp.picke.domain.oauth.dto.LogoutResponse; @@ -31,6 +32,14 @@ public ApiResponse login( return ApiResponse.onSuccess(authService.login(provider, request)); } + @Operation(summary = "로컬(테스트 계정) 로그인") + @PostMapping("/auth/login/local") + public ApiResponse loginLocal( + @Valid @RequestBody LocalLoginRequest request + ) { + return ApiResponse.onSuccess(authService.loginLocal(request)); + } + @Operation(summary = "Access Token 재발급") @PostMapping("/auth/refresh") public ApiResponse refresh( diff --git a/src/main/java/com/swyp/picke/domain/oauth/dto/LocalLoginRequest.java b/src/main/java/com/swyp/picke/domain/oauth/dto/LocalLoginRequest.java new file mode 100644 index 00000000..90b3ceeb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/dto/LocalLoginRequest.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.oauth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "로컬(테스트 계정) 로그인 요청 객체") +public class LocalLoginRequest { + + @NotBlank + @Schema(description = "관리자가 발급한 테스트 계정 아이디", example = "qa-tester01") + private String username; + + @NotBlank + @Schema(description = "테스트 계정 비밀번호", example = "P@ssw0rd!") + private String password; +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/entity/UserLocalAccount.java b/src/main/java/com/swyp/picke/domain/oauth/entity/UserLocalAccount.java new file mode 100644 index 00000000..92a11a17 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/entity/UserLocalAccount.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.oauth.entity; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "user_local_accounts") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class UserLocalAccount extends BaseEntity { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(nullable = false, unique = true, length = 50) + private String username; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + @Builder + public UserLocalAccount(User user, String username, String passwordHash) { + this.user = user; + this.username = username; + this.passwordHash = passwordHash; + } +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java index f4c0b76a..7f3463ca 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.oauth.jwt; import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.global.common.exception.ErrorCode; import com.swyp.picke.global.common.response.ApiResponse; import jakarta.servlet.FilterChain; @@ -16,13 +17,16 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.LocalDate; import java.util.List; +import java.util.Set; @Slf4j @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; private final ObjectMapper objectMapper = new ObjectMapper(); // 1. 화이트리스트 상수 관리 @@ -34,9 +38,11 @@ public class JwtFilter extends OncePerRequestFilter { "/css", "/images", "/favicon.ico", - "/api/v1/auth", - "/swagger-ui", - "/v3/api-docs", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/swagger-ui", + "/v3/api-docs", + "/webjars", "/api/v1/home", "/api/v1/notices", "/api/test", @@ -47,7 +53,22 @@ public class JwtFilter extends OncePerRequestFilter { "/.well-known", "/api/v1/resources", "/app-ads.txt", - "/robots.txt" + "/terms", + "/privacy-policy", + "/robots.txt", + "/c/", + "/api/v1/ads" + ); + + /** + * WHITELIST는 startsWith로 매칭하므로 "/"를 넣으면 전체 인증이 무력화된다. + * 루트와 에러 포워딩처럼 정확히 일치할 때만 열어야 하는 경로는 여기 둔다. + * /error가 빠져 있으면 존재하지 않는 경로가 404 대신 401로 나온다. + * 스프링이 404를 /error로 포워딩하는데 그 경로가 다시 인증에 막히기 때문이다. + */ + private static final Set EXACT_WHITELIST = Set.of( + "/", + "/error" ); @Override @@ -86,6 +107,7 @@ protected void doFilterInternal(HttpServletRequest request, ); SecurityContextHolder.getContext().setAuthentication(authentication); + recordActivity(userId); } else { // shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단 @@ -102,6 +124,17 @@ protected void doFilterInternal(HttpServletRequest request, } } + /** + * DAU/MAU 집계를 위한 활동 기록. 대시보드 집계용 부가 기능이라 실패해도 인증 흐름을 막지 않는다. + */ + private void recordActivity(Long userId) { + try { + userDailyActivityRepository.markActive(userId, LocalDate.now()); + } catch (Exception e) { + log.warn("[JwtFilter] Failed to record daily activity for userId={}: {}", userId, e.getMessage()); + } + } + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(errorCode.getHttpStatus().value()); @@ -125,6 +158,9 @@ private String resolveToken(HttpServletRequest request) { } private boolean isWhitelisted(String uri) { + if (EXACT_WHITELIST.contains(uri)) { + return true; + } return WHITELIST.stream().anyMatch(white -> uri.equals(white) || uri.startsWith(white)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/UserLocalAccountRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/UserLocalAccountRepository.java new file mode 100644 index 00000000..094d4b8b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/UserLocalAccountRepository.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.oauth.repository; + +import com.swyp.picke.domain.oauth.entity.UserLocalAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserLocalAccountRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java index e1a75c98..b3c91eac 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java +++ b/src/main/java/com/swyp/picke/domain/oauth/repository/UserSocialAccountRepository.java @@ -4,6 +4,7 @@ import com.swyp.picke.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface UserSocialAccountRepository extends JpaRepository { @@ -14,4 +15,8 @@ Optional findByProviderAndProviderUserId( Optional findByUser(User user); void deleteByUser(User user); + + List findByProviderEmailContaining(String keyword); + + List findByUser_IdIn(List userIds); } \ No newline at end of file diff --git a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java index 4dc534d5..2a0839e5 100644 --- a/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java +++ b/src/main/java/com/swyp/picke/domain/oauth/service/AuthService.java @@ -3,14 +3,19 @@ import com.swyp.picke.domain.oauth.client.AppleOAuthClient; import com.swyp.picke.domain.oauth.client.GoogleOAuthClient; import com.swyp.picke.domain.oauth.client.KakaoOAuthClient; +import com.swyp.picke.domain.admin.dto.testaccount.request.CreateTestAccountRequest; +import com.swyp.picke.domain.admin.dto.testaccount.response.TestAccountResponse; +import com.swyp.picke.domain.oauth.dto.LocalLoginRequest; import com.swyp.picke.domain.oauth.dto.LoginRequest; import com.swyp.picke.domain.oauth.dto.LoginResponse; import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; import com.swyp.picke.domain.oauth.dto.WithdrawRequest; import com.swyp.picke.domain.oauth.entity.AuthRefreshToken; +import com.swyp.picke.domain.oauth.entity.UserLocalAccount; import com.swyp.picke.domain.oauth.entity.UserSocialAccount; import com.swyp.picke.domain.oauth.jwt.JwtProvider; import com.swyp.picke.domain.oauth.repository.AuthRefreshTokenRepository; +import com.swyp.picke.domain.oauth.repository.UserLocalAccountRepository; import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.enums.CreditType; @@ -21,6 +26,7 @@ import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.entity.UserTendencyScore; import com.swyp.picke.domain.user.entity.UserWithdrawal; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -30,6 +36,7 @@ import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Base64; import java.util.List; @@ -82,13 +90,16 @@ public class AuthService { private final AppleOAuthClient appleOAuthClient; private final UserRepository userRepository; private final UserSocialAccountRepository socialAccountRepository; + private final UserLocalAccountRepository localAccountRepository; private final AuthRefreshTokenRepository refreshTokenRepository; private final UserProfileRepository userProfileRepository; private final UserSettingsRepository userSettingsRepository; private final UserTendencyScoreRepository userTendencyScoreRepository; private final UserWithdrawalRepository userWithdrawalRepository; + private final UserDailyActivityRepository userDailyActivityRepository; private final JwtProvider jwtProvider; private final CreditService creditService; + private final PasswordEncoder passwordEncoder; public LoginResponse login(String provider, LoginRequest request) { @@ -146,6 +157,45 @@ public LoginResponse login(String provider, LoginRequest request) { } } + return issueTokensAndBuildResponse(user, isNewUser); + } + + public LoginResponse loginLocal(LocalLoginRequest request) { + UserLocalAccount localAccount = localAccountRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new CustomException(ErrorCode.AUTH_INVALID_CREDENTIALS)); + + if (!passwordEncoder.matches(request.getPassword(), localAccount.getPasswordHash())) { + throw new CustomException(ErrorCode.AUTH_INVALID_CREDENTIALS); + } + + return issueTokensAndBuildResponse(localAccount.getUser(), false); + } + + public TestAccountResponse createLocalTestAccount(CreateTestAccountRequest request) { + if (localAccountRepository.existsByUsername(request.getUsername())) { + throw new CustomException(ErrorCode.LOCAL_ACCOUNT_USERNAME_DUPLICATED); + } + + User user = User.builder() + .userTag(generateUserTag()) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + userRepository.save(user); + initializeUserDomain(user); + + localAccountRepository.save(UserLocalAccount.builder() + .user(user) + .username(request.getUsername()) + .passwordHash(passwordEncoder.encode(request.getPassword())) + .build()); + + creditService.addCredit(user.getId(), CreditType.DEFAULT_CREDIT, user.getId()); + + return new TestAccountResponse(request.getUsername(), user.getUserTag()); + } + + private LoginResponse issueTokensAndBuildResponse(User user, boolean isNewUser) { // 제재 유저 체크 if (user.getStatus() == UserStatus.BANNED) { throw new CustomException(ErrorCode.USER_BANNED); @@ -154,6 +204,8 @@ public LoginResponse login(String provider, LoginRequest request) { throw new CustomException(ErrorCode.USER_SUSPENDED); } + userDailyActivityRepository.markLoggedIn(user.getId(), LocalDate.now()); + // 기존 refresh token 삭제 후 새로 발급 refreshTokenRepository.deleteByUser(user); diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java deleted file mode 100644 index 552c30c5..00000000 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/BestCommentSchedulerTestController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.swyp.picke.domain.perspective.controller; - -import com.swyp.picke.domain.perspective.scheduler.BestCommentScheduler; -import com.swyp.picke.global.common.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Tag(name = "[Test] BestCommentScheduler", description = "스케줄러 테스트 API") -@RestController -@RequestMapping("/api/test/scheduler") -@RequiredArgsConstructor -public class BestCommentSchedulerTestController { - - private final BestCommentScheduler bestCommentScheduler; - - @Operation(summary = "베스트 댓글 정산 전체 실행", description = "PUBLISHED 상태 배틀 전체를 대상으로 베스트 댓글 포인트 정산을 즉시 실행합니다.") - @PostMapping("/best-comment") - public ApiResponse runAll() { - bestCommentScheduler.awardBestComments(); - return ApiResponse.onSuccess("베스트 댓글 정산 완료"); - } - - @Operation(summary = "베스트 댓글 정산 단건 실행", description = "특정 battleId에 대해서만 베스트 댓글 포인트 정산을 즉시 실행합니다.") - @PostMapping("/best-comment/battles/{battleId}") - public ApiResponse runByBattle(@PathVariable Long battleId) { - bestCommentScheduler.processBattle(battleId); - return ApiResponse.onSuccess("battleId=" + battleId + " 베스트 댓글 정산 완료"); - } -} diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java index 9aaf97b4..842cc265 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveController.java @@ -24,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "관점 API", description = "관점 생성, 조회, 수정, 삭제") @RestController @RequestMapping("/api/v1") @@ -63,9 +65,9 @@ public ApiResponse getPerspectives( return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionId, sort)); } - @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점을 조회합니다.") + @Operation(summary = "내 관점 조회", description = "해당 배틀에서 본인이 작성한 관점 목록을 조회합니다.") @GetMapping("/battles/{battleId}/perspectives/me") - public ApiResponse getMyPerspective( + public ApiResponse> getMyPerspective( @PathVariable Long battleId, @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(perspectiveService.getMyPerspective(battleId, userId)); diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java index f578a75e..8be987b6 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/CreatePerspectiveRequest.java @@ -1,10 +1,8 @@ package com.swyp.picke.domain.perspective.dto.request; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; public record CreatePerspectiveRequest( @NotBlank - @Size(max = 200) String content ) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java index d3a35e24..b5ca0d04 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java +++ b/src/main/java/com/swyp/picke/domain/perspective/dto/request/UpdatePerspectiveRequest.java @@ -1,10 +1,8 @@ package com.swyp.picke.domain.perspective.dto.request; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; public record UpdatePerspectiveRequest( @NotBlank - @Size(max = 200) String content ) {} diff --git a/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java index 4456b0ae..7ccefa46 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java +++ b/src/main/java/com/swyp/picke/domain/perspective/entity/Perspective.java @@ -13,7 +13,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -21,10 +20,7 @@ @Getter @Entity -@Table( - name = "perspectives", - uniqueConstraints = @UniqueConstraint(columnNames = {"battle_id", "user_id"}) -) +@Table(name = "perspectives") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Perspective extends BaseEntity { diff --git a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java index c2a683ef..fd7d3b39 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java +++ b/src/main/java/com/swyp/picke/domain/perspective/repository/PerspectiveRepository.java @@ -7,13 +7,14 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; public interface PerspectiveRepository extends JpaRepository { - boolean existsByBattleIdAndUserId(Long battleId, Long userId); + List findByBattleIdAndUserIdOrderByCreatedAtDesc(Long battleId, Long userId); - Optional findByBattleIdAndUserId(Long battleId, Long userId); + List findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + long countByUserId(Long userId); List findByBattleIdAndStatusOrderByCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable); diff --git a/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java b/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java deleted file mode 100644 index ab7ff943..00000000 --- a/src/main/java/com/swyp/picke/domain/perspective/scheduler/BestCommentScheduler.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.swyp.picke.domain.perspective.scheduler; - -import com.swyp.picke.domain.battle.entity.Battle; -import com.swyp.picke.domain.battle.enums.BattleStatus; -import com.swyp.picke.domain.battle.repository.BattleRepository; -import com.swyp.picke.domain.perspective.entity.PerspectiveComment; -import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; -import com.swyp.picke.domain.user.enums.CreditType; -import com.swyp.picke.domain.user.service.CreditService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Slf4j -@Component -@RequiredArgsConstructor -public class BestCommentScheduler { - - private static final int MIN_LIKE_COUNT = 10; - private static final int TOP_N = 3; - - private final BattleRepository battleRepository; - private final PerspectiveCommentRepository perspectiveCommentRepository; - private final CreditService creditService; - - @Scheduled(cron = "0 0 0 * * MON") - public void awardBestComments() { - log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 시작"); - - List battles = battleRepository.findByStatusAndDeletedAtIsNull(BattleStatus.PUBLISHED); - - for (Battle battle : battles) { - try { - processBattle(battle.getId()); - } catch (Exception e) { - log.error("[BestCommentScheduler] battleId={} 처리 중 오류 발생: {}", battle.getId(), e.getMessage()); - } - } - - log.info("[BestCommentScheduler] 베스트 댓글 포인트 정산 완료"); - } - - @Transactional - public void processBattle(Long battleId) { - List topComments = perspectiveCommentRepository.findTopCommentsByBattleId( - battleId, - MIN_LIKE_COUNT, - PageRequest.of(0, TOP_N) - ); - - if (topComments.isEmpty()) { - return; - } - - for (PerspectiveComment comment : topComments) { - Long userId = comment.getUser().getId(); - Long commentId = comment.getId(); - - creditService.addCredit(userId, CreditType.BEST_COMMENT, commentId); - log.info("[BestCommentScheduler] 포인트 지급 - battleId={}, commentId={}, userId={}", battleId, commentId, userId); - } - } -} diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java index e1defc95..b6fec9c9 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveQueryService.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.perspective.service; +import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.entity.PerspectiveComment; import com.swyp.picke.domain.perspective.entity.PerspectiveLike; import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -18,9 +20,10 @@ public class PerspectiveQueryService { private final PerspectiveCommentRepository perspectiveCommentRepository; private final PerspectiveLikeRepository perspectiveLikeRepository; + private final PerspectiveRepository perspectiveRepository; - public List findUserComments(Long userId, int offset, int size) { - PageRequest pageable = PageRequest.of(offset / size, size); + public List findUserComments(Long userId, int limit) { + PageRequest pageable = PageRequest.of(0, limit); return perspectiveCommentRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); } @@ -28,6 +31,15 @@ public long countUserComments(Long userId) { return perspectiveCommentRepository.countByUserId(userId); } + public List findUserPerspectives(Long userId, int limit) { + PageRequest pageable = PageRequest.of(0, limit); + return perspectiveRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserPerspectives(Long userId) { + return perspectiveRepository.countByUserId(userId); + } + public List findUserLikes(Long userId, int offset, int size) { PageRequest pageable = PageRequest.of(offset / size, size); return perspectiveLikeRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java index d987e521..1688d09b 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveService.java @@ -85,10 +85,6 @@ public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, C User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - if (perspectiveRepository.existsByBattleIdAndUserId(battleId, userId)) { - throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); - } - Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, userId); BattleOption option = battleService.findOptionById(postVoteOptionId); @@ -181,27 +177,30 @@ public UpdatePerspectiveResponse updatePerspective(Long perspectiveId, Long user return new UpdatePerspectiveResponse(perspective.getId(), perspective.getContent(), perspective.getUpdatedAt()); } - public MyPerspectiveResponse getMyPerspective(Long battleId, Long userId) { + public List getMyPerspective(Long battleId, Long userId) { battleService.findById(battleId); - Perspective perspective = perspectiveRepository.findByBattleIdAndUserId(battleId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + List perspectives = perspectiveRepository.findByBattleIdAndUserIdOrderByCreatedAtDesc(battleId, userId); UserSummary user = userQueryService.findSummaryById(userId); String characterImageUrl = resolveCharacterImageUrl(user.characterType()); - BattleOption option = perspective.getOption(); - boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); - return new MyPerspectiveResponse( - perspective.getId(), - new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), - new MyPerspectiveResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()), - perspective.getContent(), - perspective.getLikeCount(), - perspective.getCommentCount(), - isLiked, - perspective.getStatus(), - perspective.getCreatedAt() - ); + return perspectives.stream() + .map(perspective -> { + BattleOption option = perspective.getOption(); + boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(perspective, userId); + return new MyPerspectiveResponse( + perspective.getId(), + new MyPerspectiveResponse.UserSummary(user.userTag(), user.nickname(), user.characterType(), characterImageUrl), + new MyPerspectiveResponse.OptionSummary(option.getId(), BattleOptionDisplay.opinion(option), option.getTitle(), option.getStance()), + perspective.getContent(), + perspective.getLikeCount(), + perspective.getCommentCount(), + isLiked, + perspective.getStatus(), + perspective.getCreatedAt() + ); + }) + .toList(); } @Transactional diff --git a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java index ce1ebd63..61e576e2 100644 --- a/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceImpl.java @@ -69,9 +69,10 @@ public String processReward(AdMobRewardRequest request) { log.info("[AdMob] 보상 이력 저장 완료: historyId={}", history.getId()); // 6. 크레딧 적립 (history.getId()를 referenceId로 사용해 unique 충돌 방지) - creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, request.reward_amount(), history.getId()); + // AdMob이 보낸 reward_amount는 콘솔 설정에 따라 달라질 수 있어 신뢰하지 않고, 정책상 고정값(FREE_CHARGE)만 지급한다 + creditService.addCredit(user.getId(), CreditType.FREE_CHARGE, CreditType.FREE_CHARGE.getDefaultAmount(), history.getId()); log.info("[AdMob] 포인트 적립 완료: userId={}, amount={}, historyId={}", - user.getId(), request.reward_amount(), history.getId()); + user.getId(), CreditType.FREE_CHARGE.getDefaultAmount(), history.getId()); return "OK"; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java index a5b73b06..ebecb95a 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -95,6 +95,7 @@ private ScriptResponse toUserScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(cleanText) + .tone(script.getTone()) .build(); } @@ -120,12 +121,14 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(script.getText()) + .tone(script.getTone()) .build(); } private OptionResponse toUserOptionResponse(InteractiveOption option) { return OptionResponse.builder() .label(option.getLabel()) + .title(option.getTitle()) .nextNodeId(option.getNextNodeId()) .build(); } @@ -133,6 +136,7 @@ private OptionResponse toUserOptionResponse(InteractiveOption option) { private AdminScenarioOptionResponse toAdminOptionResponse(InteractiveOption option) { return AdminScenarioOptionResponse.builder() .label(option.getLabel()) + .title(option.getTitle()) .nextNodeId(option.getNextNodeId()) .build(); } diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java index bb95c110..7ba824a8 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java @@ -2,5 +2,6 @@ public record OptionRequest( String label, + String title, String nextNodeName -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java index 36984364..a3236262 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.scenario.dto.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record ScriptRequest( String speakerName, SpeakerType speakerType, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java index 189d16c3..46b76a90 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java @@ -5,5 +5,6 @@ @Builder public record OptionResponse( String label, + String title, Long nextNodeId ) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java index 7833ed20..060bad05 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.dto.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record ScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java index 0b814800..3dea59cb 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java @@ -19,12 +19,17 @@ public class InteractiveOption extends BaseEntity { private String label; + /** 선택의 시간 화면에 보여줄 선택지 설명(예: "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!"). */ + @Column(length = 255) + private String title; + @Column(name = "next_node_id") private Long nextNodeId; @Builder - public InteractiveOption(String label, Long nextNodeId) { + public InteractiveOption(String label, String title, Long nextNodeId) { this.label = label; + this.title = title; this.nextNodeId = nextNodeId; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java new file mode 100644 index 00000000..83fc07cb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java @@ -0,0 +1,61 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + * 붙여넣기 파서가 시나리오 발화자에 맞는 보이스를 채울 때 조회한다. + */ +@Getter +@Entity +@Table(name = "philosopher_voice") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhilosopherVoice extends BaseEntity { + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(name = "reference_id", nullable = false, length = 64) + private String referenceId; + + @Column(name = "voice_label", length = 100) + private String voiceLabel; + + /** 철학자 이미지 저장 키(S3/Railway Bucket). 없으면 프론트가 기본 이미지로 대체해야 한다. */ + @Column(name = "image_key", length = 500) + private String imageKey; + + @Column(length = 255) + private String note; + + @Builder + public PhilosopherVoice(String name, String referenceId, String voiceLabel, String imageKey, String note) { + this.name = name; + this.referenceId = referenceId; + this.voiceLabel = voiceLabel; + this.imageKey = imageKey; + this.note = note; + } + + public void update(String referenceId, String voiceLabel, String imageKey, String note) { + if (referenceId != null && !referenceId.isBlank()) { + this.referenceId = referenceId; + } + if (voiceLabel != null) { + this.voiceLabel = voiceLabel; + } + if (imageKey != null) { + this.imageKey = imageKey; + } + if (note != null) { + this.note = note; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java index 27e8b6e7..31b7022e 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.entity; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -29,27 +30,33 @@ public class Script extends BaseEntity { private String speakerName; @Column(columnDefinition = "TEXT") - private String text; // SSML 태그가 포함된 텍스트 + private String text; // 오디오 효과 태그([sighing] 등)가 인라인으로 포함된 텍스트 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Tone tone = Tone.NEUTRAL; @Column(name = "audio_url") private String audioUrl; @Builder - public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text) { + public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text, Tone tone) { this.startTimeMs = startTimeMs; this.speakerType = speakerType; this.speakerName = speakerName; this.text = text; + this.tone = tone == null ? Tone.NEUTRAL : tone; } public void updateAudioUrl(String audioUrl) { this.audioUrl = audioUrl; } - public void updateContent(SpeakerType speakerType, String speakerName, String newText) { + public void updateContent(SpeakerType speakerType, String speakerName, String newText, Tone newTone) { this.speakerType = speakerType; this.speakerName = speakerName; this.text = newText; + this.tone = newTone == null ? Tone.NEUTRAL : newTone; this.audioUrl = null; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java new file mode 100644 index 00000000..6623ded0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.scenario.enums; + +import java.util.Arrays; +import java.util.Optional; +import lombok.Getter; + +/** + * 대사 한 줄의 감성적인 톤. 줄당 하나. 오디오 효과 태그(문장 중간 삽입, 다중)는 이 enum이 아니라 + * Script.text 인라인에 그대로 둔다. + * + *

{@link #tag} 는 Fish Audio S2/S2.1 이 인식하는 인라인 대괄호 표기. TTS 합성 직전 text 앞에 붙인다. + * {@link #NEUTRAL} 은 아무 태그도 붙이지 않는다. + */ +@Getter +public enum Tone { + NEUTRAL(null), + ANGRY("[angry]"), + SAD("[sad]"), + EMBARRASSED("[embarrassed]"), + EMPHASIS("[emphasis]"), + WHISPERING("[whispering]"), + SOFT_TONE("[soft tone]"), + BREATHY("[breathy]"), + EXCITED("[excited]"); + + private final String tag; + + Tone(String tag) { + this.tag = tag; + } + + public boolean hasTag() { + return tag != null; + } + + /** + * 기획자가 대본에 인라인으로 써넣은 톤 태그 문자열(예: {@code [angry]}, {@code [soft]})을 enum 으로 해석. + * 표기 흔들림({@code [soft]} → SOFT_TONE)도 흡수한다. 톤이 아닌 태그면 empty. + */ + public static Optional fromInlineTag(String rawTag) { + if (rawTag == null) { + return Optional.empty(); + } + String normalized = rawTag.trim().toLowerCase() + .replace("[", "").replace("]", "").trim(); + return switch (normalized) { + case "angry" -> Optional.of(ANGRY); + case "sad" -> Optional.of(SAD); + case "embarrassed" -> Optional.of(EMBARRASSED); + case "emphasis" -> Optional.of(EMPHASIS); + case "whispering", "whisper" -> Optional.of(WHISPERING); + case "soft", "soft tone" -> Optional.of(SOFT_TONE); + case "breathy" -> Optional.of(BREATHY); + case "excited" -> Optional.of(EXCITED); + default -> Optional.empty(); + }; + } + + public static Tone fromNameOrNeutral(String name) { + if (name == null || name.isBlank()) { + return NEUTRAL; + } + return Arrays.stream(values()) + .filter(tone -> tone.name().equalsIgnoreCase(name.trim())) + .findFirst() + .orElse(NEUTRAL); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java new file mode 100644 index 00000000..1f978314 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.scenario.repository; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PhilosopherVoiceRepository extends JpaRepository { + Optional findByName(String name); + boolean existsByName(String name); +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java new file mode 100644 index 00000000..37420b05 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java @@ -0,0 +1,80 @@ +package com.swyp.picke.domain.scenario.service; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PhilosopherVoiceService { + + private final PhilosopherVoiceRepository philosopherVoiceRepository; + private final ResourceUrlProvider resourceUrlProvider; + + @Transactional(readOnly = true) + public List getAll() { + return philosopherVoiceRepository.findAll().stream() + .map(this::toResponse) + .toList(); + } + + /** 파서가 발화자 철학자에 맞는 보이스를 채울 때 사용. 없으면 empty. */ + @Transactional(readOnly = true) + public Optional findReferenceIdByName(String philosopherName) { + if (philosopherName == null || philosopherName.isBlank()) { + return Optional.empty(); + } + return philosopherVoiceRepository.findByName(philosopherName.trim()) + .map(PhilosopherVoice::getReferenceId); + } + + @Transactional + public PhilosopherVoiceResponse create(PhilosopherVoiceRequest request) { + if (philosopherVoiceRepository.existsByName(request.name().trim())) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + } + PhilosopherVoice saved = philosopherVoiceRepository.save(PhilosopherVoice.builder() + .name(request.name().trim()) + .referenceId(request.referenceId().trim()) + .voiceLabel(request.voiceLabel()) + .imageKey(request.imageKey()) + .note(request.note()) + .build()); + return toResponse(saved); + } + + @Transactional + public PhilosopherVoiceResponse update(Long id, PhilosopherVoiceRequest request) { + PhilosopherVoice entity = philosopherVoiceRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND)); + entity.update(request.referenceId(), request.voiceLabel(), request.imageKey(), request.note()); + return toResponse(entity); + } + + @Transactional + public void delete(Long id) { + if (!philosopherVoiceRepository.existsById(id)) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + philosopherVoiceRepository.deleteById(id); + } + + /** + * imageKey는 DB에 raw 저장 키로 있다가, 응답 시 다른 이미지 필드(썸네일/옵션 이미지)와 + * 동일하게 호출 가능한 경로로 변환해서 나간다({@link ResourceUrlProvider}). + */ + private PhilosopherVoiceResponse toResponse(PhilosopherVoice entity) { + String imageUrl = resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, entity.getImageKey()); + return PhilosopherVoiceResponse.from(entity, imageUrl); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java index 029a0a9c..26a3e94b 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java @@ -63,7 +63,8 @@ public void generateAndMergeAudioAsync(Long scenarioId) { audioFile = ttsService.generateTtsWithSsml( script.getText(), script.getSpeakerType(), - scenario.getVoiceCode(script.getSpeakerType()) + scenario.getVoiceCode(script.getSpeakerType()), + script.getTone() ); // 새로 만든 조각 파일을 다음 수정을 위해 S3에 업로드 (chunks 폴더) diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java index 243015fd..3229f935 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java @@ -21,6 +21,7 @@ import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.repository.ScenarioRepository; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; @@ -221,6 +222,7 @@ private boolean smartUpdateNodesToScenario(Scenario scenario, ScenarioCreateRequ .speakerType(scriptReq.speakerType()) .speakerName(autoSpeakerName) .text(scriptReq.text()) + .tone(scriptReq.tone()) .build()); } } @@ -255,6 +257,7 @@ private boolean smartUpdateNodesToScenario(Scenario scenario, ScenarioCreateRequ Optional.ofNullable(updatedNodeMap.get(optReq.nextNodeName())) .ifPresent(target -> parentNode.addOption(InteractiveOption.builder() .label(optReq.label()) + .title(optReq.title()) .nextNodeId(target.getId()) .build())); } @@ -291,9 +294,11 @@ private boolean updateScriptsSmartly(ScenarioNode existingNode, java.util.List tags = (type != null) ? tagRepository.findAllByTypeAndDeletedAtIsNull(type) : tagRepository.findAllByDeletedAtIsNull(); - return TagConverter.toListResponse(tags); + return TagConverter.toListResponse(tags.stream() + .filter(this::isSupportedPhilosopherTag) + .toList()); } @Override @Transactional @PreAuthorize("hasRole('ADMIN')") public TagResponse createTag(TagRequest request) { + validatePhilosopherTag(request.name(), request.type()); validateDuplicateTag(request.name(), request.type()); Tag newTag = TagConverter.toEntity(request); @@ -66,6 +70,7 @@ public TagResponse createTag(TagRequest request) { @PreAuthorize("hasRole('ADMIN')") public TagResponse updateTag(Long tagId, TagRequest request) { Tag tag = findTagById(tagId); + validatePhilosopherTag(request.name(), request.type()); boolean typeChanged = tag.getType() != request.type(); if (!tag.getName().equals(request.name()) || tag.getType() != request.type()) { @@ -105,6 +110,17 @@ private void validateDuplicateTag(String name, TagType type) { } } + private void validatePhilosopherTag(String name, TagType type) { + if (type == TagType.PHILOSOPHER && PhilosopherType.fromLabel(name) == null) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + private boolean isSupportedPhilosopherTag(Tag tag) { + return tag.getType() != TagType.PHILOSOPHER + || PhilosopherType.fromLabel(tag.getName()) != null; + } + private boolean isTagInUse(Tag tag) { return battleTagRepository.existsByTag(tag) || battleOptionTagRepository.existsByTag(tag); } diff --git a/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java new file mode 100644 index 00000000..414c8e81 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/entity/UserDailyActivity.java @@ -0,0 +1,38 @@ +package com.swyp.picke.domain.user.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록. + * {@code active}/{@code logged_in}은 JwtFilter/AuthService에서 UPSERT로 기록되며, + * 이 엔티티는 조회 전용으로만 사용한다. + */ +@Getter +@Entity +@Table( + name = "user_daily_activities", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "activity_date"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserDailyActivity extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "activity_date", nullable = false) + private LocalDate activityDate; + + @Column(name = "logged_in", nullable = false) + private boolean loggedIn; + + @Column(name = "active", nullable = false) + private boolean active; +} diff --git a/src/main/java/com/swyp/picke/domain/user/entity/UserTendencyScore.java b/src/main/java/com/swyp/picke/domain/user/entity/UserTendencyScore.java index 7cb2ae64..59c37cfc 100644 --- a/src/main/java/com/swyp/picke/domain/user/entity/UserTendencyScore.java +++ b/src/main/java/com/swyp/picke/domain/user/entity/UserTendencyScore.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.user.entity; +import com.swyp.picke.domain.user.enums.ValueAxis; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -57,4 +58,22 @@ public void update(int principle, int reason, int individual, this.inner = inner; this.ideal = ideal; } + + public void applyValueTag(String tagName) { + applyValueTag(tagName, 1); + } + + public void applyValueTag(String tagName, int multiplier) { + ValueAxis.resolve(tagName).ifPresent(value -> { + int direction = value.direction() * multiplier; + switch (value.axis()) { + case PRINCIPLE -> principle += direction; + case REASON -> reason += direction; + case INDIVIDUAL -> individual += direction; + case CHANGE -> change += direction; + case INNER -> inner += direction; + case IDEAL -> ideal += direction; + } + }); + } } diff --git a/src/main/java/com/swyp/picke/domain/user/enums/ActivityType.java b/src/main/java/com/swyp/picke/domain/user/enums/ActivityType.java index 2fb52195..e853d99e 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/ActivityType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/ActivityType.java @@ -2,5 +2,6 @@ public enum ActivityType { COMMENT, - LIKE + LIKE, + PERSPECTIVE } diff --git a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java index 03519c34..a9cd46c0 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java @@ -5,13 +5,11 @@ @Getter public enum CreditType { TODAY_CREDIT(5), // 출석체크: 하루 1회 - ATTENDANCE_STREAK(7), // 7일 연속 출석 보너스 + ATTENDANCE_STREAK(7), // 월~토 개근 후 일요일 출석체크 (TODAY_CREDIT 대신 지급) DEFAULT_CREDIT(30), // 기본 크레딧: 회원가입 시 기본 지급 - BATTLE_VOTE(5), // 배틀 참여 보상: 사후 투표 완료 시 즉시 지급 BATTLE_ENTRY(-5), // 지난 배틀 이용 비용: 사전 투표 최초 진입 시 차감 - MAJORITY_WIN(10), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상 + MAJORITY_WIN(5), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상 BEST_COMMENT(15), // 베댓 보상: 월요일 배치, 2주 전 배틀 좋아요 1위 - WEEKLY_CHARGE(40), // 주간 자동 충전: 매주 월요일 00:00 활성 사용자 전체 FREE_CHARGE(20), // 광고/자유 충전: 가변 금액 TOPIC_SUGGEST(100), // 주제 제안 TOPIC_ADOPTED(120); // 주제 채택 diff --git a/src/main/java/com/swyp/picke/domain/user/enums/PhilosopherType.java b/src/main/java/com/swyp/picke/domain/user/enums/PhilosopherType.java index 25c24a0f..4e55536b 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/PhilosopherType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/PhilosopherType.java @@ -52,29 +52,11 @@ public enum PhilosopherType { "BUDDHA", "CONFUCIUS", 22, 38, 68, 88, 94, 70, "images/philosophers/laozi.png"), - BUDDHA("붓다", "내면형", "외부의 소음에서 벗어나 마음속 깊은 평화와 고요를 찾는 수행자", + BUDDHA("석가모니", "내면형", "외부의 소음에서 벗어나 마음속 깊은 평화와 고요를 찾는 수행자", List.of("#평화", "#내면", "#명상", "#해탈"), "LAOZI", "ARISTOTLE", 35, 55, 42, 48, 96, 62, - "images/philosophers/buddha.png"), - AQUINAS("토마스 아퀴나스", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/aquinas.png"), - CAMUS("카뮈", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/camus.png"), - CHOE_HANGI("최한기", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/choe_hangi.png"), - DESCARTES("데카르트", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/descartes.png"), - EPICURUS("에피쿠로스", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/epicurus.png"), - FROMM("에리히 프롬", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/fromm.png"), - HOBBES("홉스", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/hobbes.png"), - HUME("흄", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/hume.png"), - JEONG_YAKYONG("정약용", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/jeong_yakyong.png"), - JUNG("융", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/jung.png"), - LEIBNIZ("라이프니츠", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/leibniz.png"), - MENCIUS("맹자", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/mencius.png"), - MILL("존 스튜어트 밀", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/mill.png"), - RAWLS("롤스", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/rawls.png"), - SCHOPENHAUER("쇼펜하우어", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/schopenhauer.png"), - XUNZI("순자", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/xunzi.png"), - YI_HWANG("이황", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/yi_hwang.png"), - YI_I("이이", null, null, null, null, 0, 0, 0, 0, 0, 0, "images/philosophers/yi_i.png"); + "images/philosophers/buddha.png"); private final String label; private final String typeName; @@ -111,16 +93,6 @@ public enum PhilosopherType { this.imageKey = imageKey; } - PhilosopherType(String label, String typeName, String description, - String bestMatchName, String worstMatchName, - int principle, int reason, int individual, - int change, int inner, int ideal, - String imageKey) { - this(label, typeName, description, List.of(), - bestMatchName, worstMatchName, - principle, reason, individual, change, inner, ideal, imageKey); - } - public PhilosopherType getBestMatch() { return bestMatchName != null ? valueOf(bestMatchName) : null; } diff --git a/src/main/java/com/swyp/picke/domain/user/enums/ValueAxis.java b/src/main/java/com/swyp/picke/domain/user/enums/ValueAxis.java new file mode 100644 index 00000000..f0a9106c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/enums/ValueAxis.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.user.enums; + +import java.util.Arrays; +import java.util.Optional; + +public enum ValueAxis { + PRINCIPLE("원칙", "결과"), + REASON("이성", "감성"), + INDIVIDUAL("개인", "관계"), + CHANGE("변화", "전통"), + INNER("내면", "구조"), + IDEAL("이상", "현실"); + + private final String positiveTag; + private final String negativeTag; + + ValueAxis(String positiveTag, String negativeTag) { + this.positiveTag = positiveTag; + this.negativeTag = negativeTag; + } + + public static Optional resolve(String tagName) { + return Arrays.stream(values()) + .filter(axis -> axis.positiveTag.equals(tagName) || axis.negativeTag.equals(tagName)) + .findFirst() + .map(axis -> new ValueDirection( + axis, + axis.positiveTag.equals(tagName) ? 1 : -1 + )); + } + + public record ValueDirection(ValueAxis axis, int direction) { + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java index d420cca0..ccf8477b 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java @@ -2,6 +2,9 @@ import com.swyp.picke.domain.user.entity.CreditHistory; import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.repository.projection.CreditTypeStats; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -21,4 +24,21 @@ public interface CreditHistoryRepository extends JpaRepository findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + long countByCreditTypeAndCreatedAtBetween(CreditType creditType, LocalDateTime start, LocalDateTime end); + + /** + * 어드민 대시보드용: from~to 구간의 크레딧 타입별 지급/차감 건수 및 총액. + */ + @Query(""" + SELECT c.creditType AS creditType, COUNT(c) AS count, COALESCE(SUM(c.amount), 0) AS totalAmount + FROM CreditHistory c + WHERE c.createdAt >= :start AND c.createdAt < :end + GROUP BY c.creditType + ORDER BY c.creditType + """) + List findStatsByCreatedAtBetween( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end + ); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java new file mode 100644 index 00000000..a4e995fe --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserDailyActivityRepository.java @@ -0,0 +1,64 @@ +package com.swyp.picke.domain.user.repository; + +import com.swyp.picke.domain.user.entity.UserDailyActivity; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface UserDailyActivityRepository extends JpaRepository { + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, false, true, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET active = true, updated_at = now() + """, nativeQuery = true) + void markActive(@Param("userId") Long userId, @Param("date") LocalDate date); + + @Modifying + @Transactional + @Query(value = """ + INSERT INTO user_daily_activities (user_id, activity_date, logged_in, active, created_at, updated_at) + VALUES (:userId, :date, true, false, now(), now()) + ON CONFLICT (user_id, activity_date) + DO UPDATE SET logged_in = true, updated_at = now() + """, nativeQuery = true) + void markLoggedIn(@Param("userId") Long userId, @Param("date") LocalDate date); + + long countByActivityDateAndLoggedInTrue(LocalDate activityDate); + + long countByActivityDateAndActiveTrue(LocalDate activityDate); + + /** + * from~to 구간의 일자별 DAU(그날 active=true인 유저 수). 활동이 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date = gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간의 일자별 MAU(그날 기준 최근 30일 롤링 윈도우의 distinct active 유저 수). + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(DISTINCT uda.user_id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN user_daily_activities uda + ON uda.activity_date BETWEEN (gs::date - INTERVAL '29 days') AND gs::date AND uda.active = true + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findRollingMonthlyActiveUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index bc4a352f..9bf3d747 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -3,8 +3,13 @@ import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -29,4 +34,45 @@ public interface UserRepository extends JpaRepository { List findAllByStatus(UserStatus status); List findAllByRole(UserRole role); + + @Query(""" + select u from User u + where u.nickname like concat('%', :keyword, '%') + or u.userTag like concat('%', :keyword, '%') + order by u.id desc + """) + Slice searchByNicknameOrUserTag(@Param("keyword") String keyword, Pageable pageable); + + long countByStatus(UserStatus status); + + long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); + + /** + * from~to 구간의 일자별 신규 가입자 수. 가입자가 없는 날짜도 0으로 채워서 반환한다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series(CAST(:from AS timestamp), CAST(:to AS timestamp), interval '1 day') AS gs + LEFT JOIN users u ON CAST(u.created_at AS date) = gs::date + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findDailyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); + + /** + * from~to 구간이 걸쳐있는 주(ISO 8601, 월요일 시작)별 신규 가입자 수. 가입자가 없는 주도 0으로 채워서 반환하며, + * 반환되는 날짜는 각 주의 시작일(월요일)이다. + */ + @Query(value = """ + SELECT gs::date AS activity_date, COUNT(u.id) AS count + FROM generate_series( + date_trunc('week', CAST(:from AS timestamp)), + date_trunc('week', CAST(:to AS timestamp)), + interval '1 week' + ) AS gs + LEFT JOIN users u ON date_trunc('week', u.created_at) = gs + GROUP BY gs::date + ORDER BY gs::date + """, nativeQuery = true) + List findWeeklyNewUserCounts(@Param("from") LocalDate from, @Param("to") LocalDate to); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java b/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java new file mode 100644 index 00000000..2adcab56 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/projection/CreditTypeStats.java @@ -0,0 +1,9 @@ +package com.swyp.picke.domain.user.repository.projection; + +import com.swyp.picke.domain.user.enums.CreditType; + +public interface CreditTypeStats { + CreditType getCreditType(); + long getCount(); + long getTotalAmount(); +} diff --git a/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java new file mode 100644 index 00000000..c615daa1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/repository/projection/DailyUserCount.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.user.repository.projection; + +import java.time.LocalDate; + +public interface DailyUserCount { + LocalDate getActivityDate(); + long getCount(); +} diff --git a/src/main/java/com/swyp/picke/domain/user/service/CreditService.java b/src/main/java/com/swyp/picke/domain/user/service/CreditService.java index 82c8359c..7f5dce8b 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/CreditService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/CreditService.java @@ -33,7 +33,7 @@ public class CreditService { /** * 현재 로그인한 유저에게 크레딧 적립 (기본 포인트). * 일반적인 유저 액션(투표, 관점 작성, 좋아요 등)에서 사용. - * 예: creditService.addCredit(CreditType.BATTLE_VOTE, voteId); + * 예: creditService.addCredit(CreditType.BATTLE_ENTRY, battleId); */ @Transactional public void addCredit(CreditType creditType, Long referenceId) { diff --git a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java index 9f6d2302..d148977a 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java @@ -33,7 +33,9 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.stream.Stream; import java.util.Map; @Service @@ -156,8 +158,7 @@ public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, V List items = votes.stream() .map(BattleVote -> { Battle battle = BattleVote.getBattle(); - BattleOption selectedOption = BattleVote.getPostVoteOption() != null - ? BattleVote.getPostVoteOption() : BattleVote.getPreVoteOption(); + BattleOption selectedOption = BattleVote.getPostVoteOption(); VoteSide side = BattleOptionDisplay.voteSide(selectedOption); String category = categoryMap.get(battle.getId()); @@ -190,25 +191,48 @@ public ContentActivityListResponse getContentActivities(Integer offset, Integer } private ContentActivityListResponse buildCommentActivities(User user, int pageOffset, int pageSize) { - List comments = perspectiveQueryService.findUserComments(user.getId(), pageOffset, pageSize); - long totalCount = perspectiveQueryService.countUserComments(user.getId()); + int limit = pageOffset + pageSize; + + List comments = perspectiveQueryService.findUserComments(user.getId(), limit); + List myPerspectives = perspectiveQueryService.findUserPerspectives(user.getId(), limit); UserProfile profile = userService.findUserProfile(user.getId()); String myCharacterImageUrl = resolveCharacterImageUrl(profile.getCharacterType()); - List perspectives = comments.stream().map(PerspectiveComment::getPerspective).toList(); - Map battleMap = loadBattles(perspectives); - Map optionMap = loadOptions(perspectives); + List lookupTargets = new ArrayList<>(comments.stream().map(PerspectiveComment::getPerspective).toList()); + lookupTargets.addAll(myPerspectives); + Map battleMap = loadBattles(lookupTargets); + Map optionMap = loadOptions(lookupTargets); + List commentedBattleIds = comments.stream() + .map(comment -> comment.getPerspective().getBattle().getId()) + .distinct() + .toList(); + Map commentOptionMap = voteQueryService.findPostVoteOptionsByBattleIds( + user.getId(), commentedBattleIds); - List items = comments.stream() + Stream commentItems = comments.stream() .map(comment -> { Perspective p = comment.getPerspective(); return toActivityItem(comment.getId().toString(), ActivityType.COMMENT, p, - battleMap.get(p.getBattle().getId()), optionMap.get(p.getOption().getId()), - comment.getContent(), comment.getCreatedAt(), myCharacterImageUrl); - }) + comment.getUser().getId(), + battleMap.get(p.getBattle().getId()), commentOptionMap.get(p.getBattle().getId()), + comment.getContent(), comment.getLikeCount(), comment.getCreatedAt(), myCharacterImageUrl); + }); + + Stream perspectiveItems = myPerspectives.stream() + .map(p -> toActivityItem(p.getId().toString(), ActivityType.PERSPECTIVE, p, + p.getUser().getId(), + battleMap.get(p.getBattle().getId()), optionMap.get(p.getOption().getId()), + p.getContent(), p.getLikeCount(), p.getCreatedAt(), myCharacterImageUrl)); + + List items = Stream.concat(commentItems, perspectiveItems) + .sorted(Comparator.comparing(ContentActivityListResponse.ContentActivityItem::createdAt).reversed()) + .skip(pageOffset) + .limit(pageSize) .toList(); + long totalCount = perspectiveQueryService.countUserComments(user.getId()) + + perspectiveQueryService.countUserPerspectives(user.getId()); int nextOffset = pageOffset + pageSize; boolean hasNext = nextOffset < totalCount; return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); @@ -228,8 +252,9 @@ private ContentActivityListResponse buildLikeActivities(User user, int pageOffse UserSummary perspectiveAuthor = userService.findSummaryById(p.getUser().getId()); String authorCharacterImageUrl = resolveCharacterImageUrl(perspectiveAuthor.characterType()); return toActivityItem(like.getId().toString(), ActivityType.LIKE, p, + p.getUser().getId(), battleMap.get(p.getBattle().getId()), optionMap.get(p.getOption().getId()), - p.getContent(), like.getCreatedAt(), authorCharacterImageUrl); + p.getContent(), p.getLikeCount(), like.getCreatedAt(), authorCharacterImageUrl); }) .toList(); @@ -240,10 +265,11 @@ private ContentActivityListResponse buildLikeActivities(User user, int pageOffse private ContentActivityListResponse.ContentActivityItem toActivityItem( String activityId, ActivityType activityType, Perspective perspective, - Battle battle, BattleOption option, String content, LocalDateTime createdAt, + Long authorUserId, + Battle battle, BattleOption option, String content, int likeCount, LocalDateTime createdAt, String characterImageUrl) { - UserSummary author = userService.findSummaryById(perspective.getUser().getId()); + UserSummary author = userService.findSummaryById(authorUserId); ContentActivityListResponse.AuthorInfo authorInfo = new ContentActivityListResponse.AuthorInfo( author.userTag(), author.nickname(), @@ -263,7 +289,7 @@ private ContentActivityListResponse.ContentActivityItem toActivityItem( voteSide, optionTitle, content, - perspective.getLikeCount(), + likeCount, createdAt ); } diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java b/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java index 83e18bec..5203f84c 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java @@ -9,14 +9,13 @@ /** * 매주 월요일 00:00 (KST) 에 크레딧 주간 배치를 실행한다. * - * 세 잡을 순차로 돌린다: - * 1) 다수결 보상 (+10P) — 2주 전 배틀, 총 투표수 ≥ 10 인 건의 승수 옵션 투표자 전원 - * 2) 베댓 보상 (+50P) — 2주 전 배틀의 Perspective 좋아요 1위 작성자 - * 3) 주간 자동 충전 (+40P) — 활성 사용자 전체 + * 다수결 보상 (+5P) — 2주 전 배틀, 총 투표수 ≥ 10 인 건의 승수 옵션 투표자 전원 * - * 다수결/베댓은 동일 스냅샷 윈도우(14~20일 전 targetDate)를 공유한다. - * 각 잡의 referenceId 가 결정적(배틀ID / perspectiveID / 주차코드)이므로 - * CreditHistory 유니크 제약으로 중복 실행 시에도 추가 적립은 발생하지 않는다. + * 잡의 referenceId 가 결정적(배틀ID)이므로 CreditHistory 유니크 제약으로 + * 중복 실행 시에도 추가 적립은 발생하지 않는다. + * + * 베댓 보상(BestCommentRewardJob, +15P)은 정책표상 "미구현 보류" 항목이라 비활성화했다. + * 재활성화 시 runSafely("BestCommentRewardJob", () -> bestCommentRewardJob.run(runDate)) 호출을 복원하면 된다. */ @Slf4j @Component @@ -24,8 +23,6 @@ public class CreditWeeklyBatchScheduler { private final MajorityWinRewardJob majorityWinRewardJob; - private final BestCommentRewardJob bestCommentRewardJob; - private final WeeklyChargeJob weeklyChargeJob; @Scheduled(cron = "0 0 0 ? * MON", zone = "Asia/Seoul") public void runWeeklyBatch() { @@ -33,8 +30,6 @@ public void runWeeklyBatch() { log.info("[CreditWeeklyBatch] start runDate={}", runDate); runSafely("MajorityWinRewardJob", () -> majorityWinRewardJob.run(runDate)); - runSafely("BestCommentRewardJob", () -> bestCommentRewardJob.run(runDate)); - runSafely("WeeklyChargeJob", () -> weeklyChargeJob.run(runDate)); log.info("[CreditWeeklyBatch] end runDate={}", runDate); } diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java index e687062f..3ed65123 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java @@ -21,7 +21,7 @@ * 다수결 보상 배치. * runDate(월요일) 기준 targetDate ∈ [runDate-20, runDate-14] 범위의 배틀 중 * 최다 득표 옵션(= 다수결 승자 옵션)을 선정하고, - * 그 옵션을 사전 투표한 사용자 전원에게 +10P (CreditType.MAJORITY_WIN) 를 지급한다. + * 그 옵션을 사전 투표한 사용자 전원에게 +5P (CreditType.MAJORITY_WIN) 를 지급한다. * * referenceId = battleId. CreditHistory 유니크 제약으로 같은 배틀 재실행 시 중복 지급 없음. */ diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java deleted file mode 100644 index 38e7f3ba..00000000 --- a/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.swyp.picke.domain.user.service.batch; - -import com.swyp.picke.domain.user.entity.User; -import com.swyp.picke.domain.user.enums.CreditType; -import com.swyp.picke.domain.user.enums.UserStatus; -import com.swyp.picke.domain.user.repository.UserRepository; -import com.swyp.picke.domain.user.service.CreditService; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * 주간 자동 충전 배치. - * 활성 사용자(UserStatus.ACTIVE) 전원에게 +40P (CreditType.WEEKLY_CHARGE) 지급. - * - * referenceId = 배치 실행 월요일의 yyyyMMdd 정수. 같은 주차 재실행 시 중복 지급 없음. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class WeeklyChargeJob { - - private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); - - private final UserRepository userRepository; - private final CreditService creditService; - - @Transactional - public void run(LocalDate runDate) { - Long weekKey = Long.parseLong(runDate.format(KEY_FORMAT)); - List activeUsers = userRepository.findAllByStatus(UserStatus.ACTIVE); - - log.info("[WeeklyChargeJob] runDate={} activeUsers={}", runDate, activeUsers.size()); - - for (User user : activeUsers) { - creditService.addCredit(user.getId(), CreditType.WEEKLY_CHARGE, weekKey); - } - } -} diff --git a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java index 07bbf0bb..f5273691 100644 --- a/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java +++ b/src/main/java/com/swyp/picke/domain/vote/repository/BattleVoteRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -21,6 +22,13 @@ public interface BattleVoteRepository extends JpaRepository { @Query("SELECT v FROM BattleVote v LEFT JOIN FETCH v.postVoteOption WHERE v.battle.id = :battleId AND v.user.id = :userId") Optional findByBattleIdAndUserIdWithOption(@Param("battleId") Long battleId, @Param("userId") Long userId); + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle LEFT JOIN FETCH v.postVoteOption " + + "WHERE v.user.id = :userId AND v.battle.id IN :battleIds") + List findByUserIdAndBattleIdInWithPostVoteOption( + @Param("userId") Long userId, + @Param("battleIds") List battleIds + ); + Optional findByBattleAndUser(Battle battle, User user); long countByBattle(Battle battle); @@ -33,27 +41,35 @@ public interface BattleVoteRepository extends JpaRepository { Optional findTopByBattleOrderByUpdatedAtDesc(Battle battle); - @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + - "WHERE v.user.id = :userId ORDER BY v.createdAt DESC") - List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + // 내 배틀 기록: 사후투표까지 완료한 기록만 노출 + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption JOIN FETCH v.postVoteOption " + + "WHERE v.user.id = :userId AND v.postVoteOption IS NOT NULL ORDER BY v.createdAt DESC") + List findByUserIdAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); - @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + - "WHERE v.user.id = :userId AND v.preVoteOption.displayOrder = :displayOrder ORDER BY v.createdAt DESC") - List findByUserIdAndPreVoteOptionDisplayOrderOrderByCreatedAtDesc( + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption JOIN FETCH v.postVoteOption " + + "WHERE v.user.id = :userId AND v.preVoteOption.displayOrder = :displayOrder AND v.postVoteOption IS NOT NULL ORDER BY v.createdAt DESC") + List findByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNullOrderByCreatedAtDesc( @Param("userId") Long userId, @Param("displayOrder") Integer displayOrder, Pageable pageable); - @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + - "WHERE v.user.id = :userId AND v.preVoteOption.displayOrder <> :displayOrder ORDER BY v.createdAt DESC") - List findByUserIdAndPreVoteOptionDisplayOrderNotOrderByCreatedAtDesc( + @Query("SELECT v FROM BattleVote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption JOIN FETCH v.postVoteOption " + + "WHERE v.user.id = :userId AND v.preVoteOption.displayOrder <> :displayOrder AND v.postVoteOption IS NOT NULL ORDER BY v.createdAt DESC") + List findByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNullOrderByCreatedAtDesc( @Param("userId") Long userId, @Param("displayOrder") Integer displayOrder, Pageable pageable); - long countByUserId(Long userId); + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.postVoteOption IS NOT NULL") + long countByUserIdAndPostVoteOptionIsNotNull(@Param("userId") Long userId); + + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.displayOrder = :displayOrder AND v.postVoteOption IS NOT NULL") + long countByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNull(@Param("userId") Long userId, @Param("displayOrder") Integer displayOrder); - @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.displayOrder = :displayOrder") - long countByUserIdAndPreVoteOptionDisplayOrder(@Param("userId") Long userId, @Param("displayOrder") Integer displayOrder); + @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.displayOrder <> :displayOrder AND v.postVoteOption IS NOT NULL") + long countByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNull(@Param("userId") Long userId, @Param("displayOrder") Integer displayOrder); + + // countTotalParticipation 등 "사전투표만 해도 참여로 집계"하는 통계용 — postVoteOption 필터 없음 + long countByUserId(Long userId); - @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId AND v.preVoteOption.displayOrder <> :displayOrder") - long countByUserIdAndPreVoteOptionDisplayOrderNot(@Param("userId") Long userId, @Param("displayOrder") Integer displayOrder); + // 오늘의 배틀 무료 참여(일 1회) 제한 체크용: 유저가 오늘 날짜인 배틀에 이미 참여했는지 확인 + boolean existsByUserIdAndBattle_TargetDate(Long userId, LocalDate targetDate); @Query("SELECT COUNT(v) FROM BattleVote v WHERE v.user.id = :userId " + "AND v.postVoteOption IS NOT NULL " + diff --git a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java index d4461436..b38872e3 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java @@ -3,12 +3,16 @@ import com.swyp.picke.domain.battle.entity.Battle; import com.swyp.picke.domain.battle.entity.BattleOption; import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserTendencyScore; import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository; import com.swyp.picke.domain.user.service.CreditService; import com.swyp.picke.domain.user.service.UserBattleService; import com.swyp.picke.domain.vote.converter.VoteConverter; @@ -43,7 +47,9 @@ public class BattleVoteServiceImpl implements BattleVoteService { private final BattleVoteRepository battleVoteRepository; private final BattleService battleService; private final BattleOptionRepository battleOptionRepository; + private final BattleOptionTagRepository battleOptionTagRepository; private final UserRepository userRepository; + private final UserTendencyScoreRepository userTendencyScoreRepository; private final UserBattleService userBattleService; private final CreditService creditService; private final ApplicationEventPublisher eventPublisher; @@ -129,7 +135,7 @@ public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest reques vote = existingVote.get(); vote.updatePreVote(option); } else { - if (shouldChargeBattleEntryCredit(battle)) { + if (shouldChargeBattleEntryCredit(battle, user)) { creditService.addCredit(user.getId(), CreditType.BATTLE_ENTRY, battle.getId()); } vote = BattleVote.createPreVote(user, battle, option); @@ -165,16 +171,27 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque throw new CustomException(ErrorCode.PRE_VOTE_REQUIRED); } + BattleOption previousOption = vote.getPostVoteOption(); vote.doPostVote(option); + userTendencyScoreRepository.findByUserId(userId).ifPresent(score -> { + if (previousOption != null) { + applyValueTags(score, previousOption, -1); + } + applyValueTags(score, option, 1); + }); userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); eventPublisher.publishEvent(new VoteUpdatedEvent(battleId)); - // 사후 투표 완료 보상 +5P. referenceId=voteId 로 (user, BATTLE_VOTE, voteId) 유니크 제약에 의해 중복 지급 방지. - creditService.addCredit(user.getId(), CreditType.BATTLE_VOTE, vote.getId()); - return new VoteResultResponse(vote.getId(), UserBattleStep.COMPLETED); } + private void applyValueTags(UserTendencyScore score, BattleOption option, int multiplier) { + battleOptionTagRepository.findByBattleOption(option).stream() + .map(optionTag -> optionTag.getTag()) + .filter(tag -> tag.getType() == TagType.VALUE) + .forEach(tag -> score.applyValueTag(tag.getName(), multiplier)); + } + @Override @Transactional public void deleteVotesByBattleId(Long battleId) { @@ -202,8 +219,13 @@ public void completeTts(Long battleId, Long userId) { userBattleService.upsertStep(user, battle, UserBattleStep.POST_VOTE); } - private boolean shouldChargeBattleEntryCredit(Battle battle) { + // 오늘의 배틀(targetDate == 오늘)은 하루 1회에 한해 무료 진입, 그 외 진입 또는 이미 오늘 무료 진입을 사용한 경우 -5P 차감 + private boolean shouldChargeBattleEntryCredit(Battle battle, User user) { LocalDate today = LocalDate.now(KST); - return battle.getTargetDate() == null || !battle.getTargetDate().isEqual(today); + boolean isTodayBattle = battle.getTargetDate() != null && battle.getTargetDate().isEqual(today); + if (!isTodayBattle) { + return true; + } + return battleVoteRepository.existsByUserIdAndBattle_TargetDate(user.getId(), today); } } diff --git a/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java b/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java index 24b5a31e..1b96099a 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/VoteQueryService.java @@ -5,7 +5,9 @@ import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -21,22 +23,36 @@ public class VoteQueryService { public List findUserVotes(Long userId, int offset, int size, VoteSide voteSide) { PageRequest pageable = PageRequest.of(offset / size, size); if (voteSide == VoteSide.PRO) { - return battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderOrderByCreatedAtDesc(userId, 1, pageable); + return battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(userId, 1, pageable); } if (voteSide == VoteSide.CON) { - return battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderNotOrderByCreatedAtDesc(userId, 1, pageable); + return battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(userId, 1, pageable); } - return battleVoteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + return battleVoteRepository.findByUserIdAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(userId, pageable); } public long countUserVotes(Long userId, VoteSide voteSide) { if (voteSide == VoteSide.PRO) { - return battleVoteRepository.countByUserIdAndPreVoteOptionDisplayOrder(userId, 1); + return battleVoteRepository.countByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNull(userId, 1); } if (voteSide == VoteSide.CON) { - return battleVoteRepository.countByUserIdAndPreVoteOptionDisplayOrderNot(userId, 1); + return battleVoteRepository.countByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNull(userId, 1); } - return battleVoteRepository.countByUserId(userId); + return battleVoteRepository.countByUserIdAndPostVoteOptionIsNotNull(userId); + } + + public Map findPostVoteOptionsByBattleIds(Long userId, List battleIds) { + if (battleIds.isEmpty()) { + return Map.of(); + } + + return battleVoteRepository.findByUserIdAndBattleIdInWithPostVoteOption(userId, battleIds).stream() + .filter(vote -> vote.getPostVoteOption() != null) + .collect(Collectors.toMap( + vote -> vote.getBattle().getId(), + BattleVote::getPostVoteOption, + (first, second) -> second + )); } public long countTotalParticipation(Long userId) { diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 046d7070..201c9c9c 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -26,6 +26,8 @@ public enum ErrorCode { AUTH_ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_ACCESS", "Access Token이 만료되었습니다."), AUTH_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_REFRESH", "Refresh Token이 만료되었습니다. 다시 로그인이 필요합니다."), AUTH_FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_403", "해당 API 접근 권한(관리자 권한)이 없습니다."), + AUTH_INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_401_CRED", "아이디 또는 비밀번호가 올바르지 않습니다."), + LOCAL_ACCOUNT_USERNAME_DUPLICATED(HttpStatus.CONFLICT, "AUTH_409_USERNAME", "이미 사용 중인 아이디입니다."), // User USER_BANNED(HttpStatus.FORBIDDEN, "USER_403_BAN", "영구 제재된 사용자입니다."), @@ -47,6 +49,8 @@ public enum ErrorCode { // Notification NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404", "존재하지 않는 알림입니다."), + NOTIFICATION_SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_SCHEDULE", "존재하지 않는 알림 예약입니다."), + NOTIFICATION_DELIVERY_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_DELIVERY_RESULT", "발송 결과가 집계되지 않은 알림입니다."), // TTS TTS_INVALID_VOICE_ID(HttpStatus.BAD_REQUEST, "TTS_400_VOICE", "TTS 보이스 ID가 유효하지 않습니다."), @@ -66,6 +70,8 @@ public enum ErrorCode { SCENARIO_NOT_FOUND(HttpStatus.NOT_FOUND, "SCENARIO_404", "존재하지 않는 시나리오입니다."), SCENARIO_ALREADY_EXISTS(HttpStatus.CONFLICT, "SCENARIO_409_DUP", "해당 배틀에 이미 시나리오가 존재합니다."), SCENARIO_ALREADY_PUBLISHED(HttpStatus.CONFLICT, "SCENARIO_409_PUB", "이미 발행된 시나리오는 수정할 수 없습니다."), + PHILOSOPHER_VOICE_NOT_FOUND(HttpStatus.NOT_FOUND, "PHIL_VOICE_404", "존재하지 않는 철학자 보이스 매핑입니다."), + PHILOSOPHER_VOICE_DUPLICATED(HttpStatus.CONFLICT, "PHIL_VOICE_409_DUP", "이미 등록된 철학자 이름입니다."), // Tag TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "TAG_404", "존재하지 않는 태그입니다."), @@ -78,7 +84,6 @@ public enum ErrorCode { // Perspective PERSPECTIVE_NOT_FOUND(HttpStatus.NOT_FOUND, "PERSPECTIVE_404", "존재하지 않는 관점입니다."), - PERSPECTIVE_ALREADY_EXISTS(HttpStatus.CONFLICT, "PERSPECTIVE_409", "이미 관점을 작성한 배틀입니다."), PERSPECTIVE_FORBIDDEN(HttpStatus.FORBIDDEN, "PERSPECTIVE_403", "본인 관점만 수정/삭제할 수 있습니다."), PERSPECTIVE_POST_VOTE_REQUIRED(HttpStatus.CONFLICT, "PERSPECTIVE_VOTE_409", "사후 투표가 완료되지 않았습니다."), PERSPECTIVE_MODERATION_NOT_FAILED(HttpStatus.BAD_REQUEST, "PERSPECTIVE_400", "검수 실패 상태의 관점이 아닙니다."), @@ -113,6 +118,13 @@ public enum ErrorCode { PHILOSOPHER_CALC_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "USER_500_PHIL", "철학자 유형을 계산할 수 없습니다."), RECAP_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_RECAP", "존재하지 않는 리캡입니다."), + // Ad (제휴 광고) + AD_CREATIVE_NOT_FOUND(HttpStatus.NOT_FOUND, "AD_404", "존재하지 않는 광고 소재입니다."), + AD_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AD_500_CODE", "광고 소재 코드 생성에 실패했습니다."), + AD_COUPANG_PARTNER_MISMATCH(HttpStatus.BAD_REQUEST, "AD_400_LPTAG", "우리 쿠팡 파트너스 아이디가 아닌 제휴 링크입니다. 링크를 다시 확인해 주세요."), + AD_CREATIVE_MANAGED(HttpStatus.BAD_REQUEST, "AD_400_MANAGED", "매체 API가 동기화하는 소재는 수정하거나 삭제할 수 없습니다. 게재 상태만 변경할 수 있습니다."), + AD_INVALID_LANDING_URL(HttpStatus.BAD_REQUEST, "AD_400_URL", "제휴 링크 형식이 올바르지 않습니다. 인코딩된 전체 URL을 넣어 주세요."), + // Attendance ATTENDANCE_ALREADY_CHECKED(HttpStatus.CONFLICT, "ATTENDANCE_409", "오늘 이미 출석체크를 완료했습니다."); diff --git a/src/main/java/com/swyp/picke/global/common/exception/GlobalExceptionHandler.java b/src/main/java/com/swyp/picke/global/common/exception/GlobalExceptionHandler.java index aab34389..f636a717 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/swyp/picke/global/common/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.security.access.AccessDeniedException; // 추가 import org.springframework.security.authorization.AuthorizationDeniedException; // 추가 import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -40,6 +41,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotValid @ExceptionHandler({ HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class, ConstraintViolationException.class, IllegalArgumentException.class }) diff --git a/src/main/java/com/swyp/picke/global/config/S3Config.java b/src/main/java/com/swyp/picke/global/config/S3Config.java index f2e88717..923505ed 100644 --- a/src/main/java/com/swyp/picke/global/config/S3Config.java +++ b/src/main/java/com/swyp/picke/global/config/S3Config.java @@ -6,8 +6,11 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import java.net.URI; + @Configuration public class S3Config { @@ -15,12 +18,22 @@ public class S3Config { public S3Presigner s3Presigner( @Value("${spring.cloud.aws.region.static}") String region, @Value("${spring.cloud.aws.credentials.access-key}") String accessKey, - @Value("${spring.cloud.aws.credentials.secret-key}") String secretKey) { + @Value("${spring.cloud.aws.credentials.secret-key}") String secretKey, + @Value("${spring.cloud.aws.s3.endpoint:}") String endpoint, + @Value("${spring.cloud.aws.s3.path-style-access-enabled:false}") boolean pathStyleAccessEnabled) { - return S3Presigner.builder() + S3Presigner.Builder builder = S3Presigner.builder() .region(Region.of(region)) .credentialsProvider(StaticCredentialsProvider.create( AwsBasicCredentials.create(accessKey, secretKey))) - .build(); + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccessEnabled) + .build()); + + if (!endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + + return builder.build(); } } diff --git a/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java index 0a51ead4..927d7746 100644 --- a/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java @@ -1,5 +1,7 @@ package com.swyp.picke.global.config; +import java.time.Clock; +import java.time.ZoneId; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -20,4 +22,9 @@ public ThreadPoolTaskScheduler taskScheduler() { scheduler.setThreadNamePrefix("credit-scheduler-"); return scheduler; } + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } } diff --git a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java index 9f36b37a..d2bf3f0e 100644 --- a/src/main/java/com/swyp/picke/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SecurityConfig.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.oauth.jwt.JwtFilter; import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -13,6 +14,8 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -26,6 +29,7 @@ public class SecurityConfig { private final JwtProvider jwtProvider; + private final UserDailyActivityRepository userDailyActivityRepository; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -40,6 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/notices/**", "/api/test/**", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", + "/webjars/**", "/js/**", "/css/**", "/favicon.ico", "/api/v1/admin/login", "/api/v1/admin", "/result/**", @@ -51,7 +56,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/battle/**", "/.well-known/**", "/app-ads.txt", - "/robots.txt" + "/robots.txt", + "/terms", + "/privacy-policy", + "/error", + "/c/**", + "/api/v1/ads", + "/api/v1/ads/**" ).permitAll() // 2. 관리자 HTML 화면 렌더링 요청 @@ -66,12 +77,17 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) - .addFilterBefore(new JwtFilter(jwtProvider), + .addFilterBefore(new JwtFilter(jwtProvider, userDailyActivityRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); diff --git a/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java b/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java index 2aada9a9..b4adb358 100644 --- a/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java +++ b/src/main/java/com/swyp/picke/global/config/SwaggerConfig.java @@ -85,7 +85,7 @@ public GroupedOpenApi userApi() { return GroupedOpenApi.builder() .group("1. 사용자 API") .pathsToMatch("/api/v1/**") - .pathsToExclude("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**") + .pathsToExclude("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**", "/api/v1/ads/**") .addOpenApiCustomizer(feUsedApiOnlyCustomizer()) .build(); } @@ -95,6 +95,30 @@ public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("2. 관리자 API") .pathsToMatch("/api/v1/admin/**", "/api/v1/files/**", "/api/v1/resources/**", "/api/test/**", "/api/v1/admob/**") + .pathsToExclude("/api/v1/admin/ads/**") + .build(); + } + + /** + * 제휴 광고는 별도 그룹으로 띄운다. + * 사용자 그룹은 FE_USED_OPERATIONS 화이트리스트로 걸러지므로 거기에 넣으면 어차피 보이지 않는다. + * 앱용과 관리자용을 한 그룹에 모아 광고 연동만 따로 볼 수 있게 한다. + * + *

이 그룹은 문서를 연 주소를 기본 서버로 둔다. Swagger UI는 서버 목록의 첫 번째를 골라 두는데, + * 공통 순서를 따르면 dev 주소로 문서를 열어도 Try it out이 운영으로 나간다. + * 광고는 소재 등록·삭제가 섞여 있어 잘못 쏘면 운영 데이터가 바뀐다. + * + *

프로파일로 가르지 않는다. dev 서버도 prod 프로파일로 돌기 때문에 구분이 되지 않는다. + * 상대 경로를 쓰면 운영에서 연 문서는 운영으로, dev에서 연 문서는 dev로 나간다. + * 다른 환경을 일부러 고르는 것은 아래 목록에서 여전히 가능하다. + */ + @Bean + public GroupedOpenApi adApi() { + return GroupedOpenApi.builder() + .group("3. 광고 API") + .pathsToMatch("/api/v1/ads", "/api/v1/ads/**", "/api/v1/admin/ads", "/api/v1/admin/ads/**") + .addOpenApiCustomizer(openApi -> openApi.setServers( + List.of(currentServer(), adServer(), devServer(), localServer(), prodServer()))) .build(); } @@ -106,23 +130,46 @@ public GroupedOpenApi allApi() { .build(); } - @Bean - public OpenAPI openAPI() { - // 1. 운영 서버 (8080) - Server prodServer = new Server() + // 문서를 연 주소. Swagger UI가 상대 경로를 현재 origin으로 풀어 준다. + private Server currentServer() { + return new Server() + .url("/") + .description("현재 접속한 서버"); + } + + /** + * 광고 전용 도메인. 운영에서 광고 조회·클릭·랜딩을 받는 곳이다. + * 운영 API 서버(picke.store)와 별도 서비스라 광고 그룹에서 따로 고를 수 있어야 한다. + */ + private Server adServer() { + return new Server() + .url("https://ad.picke.store") + .description("Ad Server (운영 광고 도메인)"); + } + + // 1. 운영 서버 (8080) + private Server prodServer() { + return new Server() .url("https://picke.store") .description("Production Server"); + } - // 2. 로컬 개발 서버 (8080) - Server local8080 = new Server() + // 2. 로컬 개발 서버 (8080) + private Server localServer() { + return new Server() .url("http://localhost:8080") .description("Local Development Server (8080)"); + } - // 3. 개발 서버 (8081) - Server devServer = new Server() + // 3. 개발 서버 (8081) + private Server devServer() { + return new Server() .url("https://dev.picke.store") .description("Remote Dev Server (8081)"); + } + @Bean + public OpenAPI openAPI() { SecurityScheme securityScheme = new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") @@ -134,8 +181,8 @@ public OpenAPI openAPI() { new SecurityRequirement().addList("bearerAuth"); return new OpenAPI() - // 3. 서버 리스트 등록 - .servers(List.of(prodServer, local8080, devServer)) + // 서버 리스트 등록 + .servers(List.of(prodServer(), localServer(), devServer())) .info(new Info() .title("PIQUE API 명세서") .description("PIQUE 서비스 API 명세서입니다.") diff --git a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java index 01339477..6aeddffe 100644 --- a/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java +++ b/src/main/java/com/swyp/picke/global/infra/apns/service/ApnsPushService.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; /** * iOS 디바이스에 FCM을 거치지 않고 APNs로 직접 푸시를 발송한다. @@ -35,10 +36,10 @@ public class ApnsPushService { @Value("${apns.bundle-id}") private String bundleId; - public void send(UserDevice device, String title, String body, Map data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { if (apnsClient.isEmpty()) { log.warn("APNs가 설정되지 않아 푸시를 건너뜁니다. deviceId={}", device.getId()); - return; + return CompletableFuture.completedFuture(false); } ApnsPayloadBuilder payloadBuilder = new SimpleApnsPayloadBuilder() @@ -51,18 +52,18 @@ public void send(UserDevice device, String title, String body, Map { + return apnsClient.get().sendNotification(notification).handle((response, cause) -> { if (cause != null) { log.warn("APNs 푸시 전송 실패. deviceId={}, error={}", device.getId(), cause.getMessage()); - return; + return false; } - handleResponse(device, response); + return handleResponse(device, response); }); } - private void handleResponse(UserDevice device, PushNotificationResponse response) { + private boolean handleResponse(UserDevice device, PushNotificationResponse response) { if (response.isAccepted()) { - return; + return true; } String reason = response.getRejectionReason().orElse("UNKNOWN"); @@ -71,5 +72,6 @@ private void handleResponse(UserDevice device, PushNotificationResponse data) { + public CompletableFuture send(UserDevice device, String title, String body, Map data) { + Map payload = new HashMap<>(data); + payload.put("title", title); + payload.put("body", body); + Message message = Message.builder() .setToken(device.getFcmToken()) - .putAllData(data) + .putAllData(payload) .setAndroidConfig(AndroidConfig.builder() .setPriority(AndroidConfig.Priority.HIGH) .build()) @@ -33,8 +42,13 @@ public void send(UserDevice device, String title, String body, Map audioFiles) throws Exception { - if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("병합할 파일이 없습니다."); + if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("merged audio file list is empty."); + String resolvedFfmpegPath = resolveExecutable(ffmpegPath, "ffmpeg"); - // FFmpeg에 전달할 리스트 파일 생성 (UTF-8 인코딩) File listFile = File.createTempFile("ffmpeg_list_", ".txt"); try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(listFile), "UTF-8"))) { for (File file : audioFiles) { - // 윈도우의 'C:\temp\file.mp3'를 'C:/temp/file.mp3'로 변환 String safePath = file.getAbsolutePath().replace("\\", "/"); - // FFmpeg 문법에 맞춰 file '경로' 형태로 기록 writer.write("file '" + safePath + "'"); writer.newLine(); } @@ -60,9 +99,8 @@ public File mergeAudioFiles(List audioFiles) throws Exception { File output = File.createTempFile("merged_full_", ".mp3"); - // 병합 명령어 구성 String[] cmd = { - ffmpegPath, "-y", "-f", "concat", "-safe", "0", + resolvedFfmpegPath, "-y", "-f", "concat", "-safe", "0", "-i", listFile.getAbsolutePath(), "-acodec", "libmp3lame", "-q:a", "2", output.getAbsolutePath() @@ -74,24 +112,22 @@ public File mergeAudioFiles(List audioFiles) throws Exception { } private void executeCommand(String[] cmd) throws Exception { - log.info("[FFmpeg] 명령어 실행: {}", String.join(" ", cmd)); + log.info("[FFmpeg] 명령 실행: {}", String.join(" ", cmd)); ProcessBuilder pb = new ProcessBuilder(cmd); - pb.redirectErrorStream(true); // 에러와 출력을 합침 + pb.redirectErrorStream(true); Process process = pb.start(); - // 실시간 로그 출력 (에러 원인 파악용) try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"))) { String line; while ((line = reader.readLine()) != null) { - log.info("[FFmpeg Output] {}", line); // 문제 발생 시 로그에서 바로 확인 가능 + log.info("[FFmpeg 출력] {}", line); } } int exitCode = process.waitFor(); - if (exitCode != 0) { - log.error("[FFmpeg] 프로세스 오류 발생! Exit Code: {}", exitCode); + log.error("[FFmpeg] 프로세스 오류 발생. Exit Code: {}", exitCode); throw new RuntimeException("FFmpeg 프로세스 오류 발생. Exit Code: " + exitCode); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/picke/global/infra/s3/service/S3PresignedUrlService.java b/src/main/java/com/swyp/picke/global/infra/s3/service/S3PresignedUrlService.java index 7edc0d70..4c23e88b 100644 --- a/src/main/java/com/swyp/picke/global/infra/s3/service/S3PresignedUrlService.java +++ b/src/main/java/com/swyp/picke/global/infra/s3/service/S3PresignedUrlService.java @@ -7,6 +7,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import java.net.URI; import java.time.Duration; import java.util.Map; import java.util.stream.Collectors; @@ -54,11 +55,16 @@ public Map generatePresignedUrls(Map keyMap) { )); } + /** + * DB에는 보통 순수 key만 저장되지만, AWS S3를 쓰던 시절 저장된 레거시 데이터는 + * 전체 URL(https://{bucket}.s3.{region}.amazonaws.com/{key})일 수 있어 하위 호환을 위해 파싱한다. + * 스토리지 제공자(AWS/Railway 등)에 무관하게 동작하도록 호스트가 아닌 경로 기준으로 key를 뽑는다. + */ private String extractKey(String input) { - if (input.startsWith("https://") && input.contains(".s3.") && input.contains(".amazonaws.com/")) { - int idx = input.indexOf(".amazonaws.com/") + ".amazonaws.com/".length(); - return input.substring(idx); + if (!input.startsWith("http://") && !input.startsWith("https://")) { + return input; } - return input; + String path = URI.create(input).getPath(); + return path.startsWith("/") ? path.substring(1) : path; } } diff --git a/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java index 89004aaa..eb276bed 100644 --- a/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/s3/service/S3UploadServiceImpl.java @@ -15,6 +15,7 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import java.io.File; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -78,7 +79,7 @@ public File downloadFile(String fileUrl) { try { // URL에서 순수 Key만 추출 - String pureKey = fileUrl.contains(".com/") ? fileUrl.split(".com/")[1] : fileUrl; + String pureKey = extractKey(fileUrl); // 다운로드 받을 로컬 임시 파일 경로 생성 File tempFile = File.createTempFile("s3_download_", ".mp3"); @@ -112,7 +113,7 @@ public String getPresignedUrl(String key, Duration durationUrl) { if (key == null || key.isEmpty()) return null; // URL에서 도메인을 제외한 순수 'Key'만 추출 (만약 전체 URL이 들어올 경우를 대비) - String pureKey = key.contains(".com/") ? key.split(".com/")[1] : key; + String pureKey = extractKey(key); GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() .signatureDuration(durationUrl) @@ -122,6 +123,18 @@ public String getPresignedUrl(String key, Duration durationUrl) { return s3Presigner.presignGetObject(presignRequest).url().toString(); } + /** + * DB/외부 입력에는 순수 key가 오는 게 기본이지만, 레거시 데이터나 실수로 전체 URL이 + * 들어오는 경우를 대비해 호스트가 아닌 경로 기준으로 key를 추출한다(스토리지 제공자 무관). + */ + private String extractKey(String input) { + if (!input.startsWith("http://") && !input.startsWith("https://")) { + return input; + } + String path = URI.create(input).getPath(); + return path.startsWith("/") ? path.substring(1) : path; + } + private String determineContentType(String key) { if (key.endsWith(".mp3")) return "audio/mpeg"; if (key.endsWith(".png")) return "image/png"; @@ -149,7 +162,7 @@ public void deleteFile(String fileUrl) { try { // 1. 전체 URL에서 순수 Key 추출 (기존 getPresignedUrl에 있던 방식과 동일하게 처리) - String pureKey = fileUrl.contains(".com/") ? fileUrl.split(".com/")[1] : fileUrl; + String pureKey = extractKey(fileUrl); // 2. AWS SDK v2 전용 삭제 요청 객체 생성 DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java index e037b809..49e2d767 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/ElevenLabsTtsServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; @@ -44,8 +45,8 @@ public class ElevenLabsTtsServiceImpl implements TtsService { private static final String ELEVENLABS_TTS_URL = "https://api.elevenlabs.io/v1/text-to-speech/"; @Override - public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice) throws Exception { - String actingText = rawText.replaceAll("<[^>]*>", "").trim(); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = TtsTextNormalizer.normalize(rawText, tone).replaceAll("<[^>]*>", "").trim(); String voiceId = (customVoice != null && !customVoice.isBlank()) ? customVoice.trim() : getElevenLabsVoiceId(speakerType); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java index 8376e0f1..1cb1bb14 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; @@ -31,15 +32,19 @@ public class FishAudioTtsServiceImpl implements TtsService { @Value("${fishaudio.tts.url:https://api.fish.audio/v1/tts}") private String ttsUrl; + @Value("${fishaudio.tts.model:s2.1-pro}") + private String ttsModel; + @Override - public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice) throws Exception { - String actingText = cleanTextForNaturalFlow(rawText); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = cleanTextForNaturalFlow(TtsTextNormalizer.normalize(rawText, tone)); String referenceId = resolveVoiceId(speakerType, customVoice); RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(fishAudioApiKey); + headers.set("model", ttsModel); Map requestBody = new HashMap<>(); requestBody.put("text", actingText); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java index d4332b4f..3fc0aa9a 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java @@ -1,8 +1,9 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import java.io.File; public interface TtsService { - File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice) throws Exception; -} \ No newline at end of file + File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice, Tone tone) throws Exception; +} diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java new file mode 100644 index 00000000..0604daeb --- /dev/null +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java @@ -0,0 +1,44 @@ +package com.swyp.picke.global.infra.tts.service; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.Map; + +/** + * TTS 전송 직전 대사 텍스트를 정리한다. + * + *

    + *
  • 줄의 대표 톤({@link Tone})을 Fish Audio S2/S2.1 인라인 표기로 맨 앞에 붙인다. {@code NEUTRAL} 은 아무것도 안 붙임.
  • + *
  • 기획자가 관용적으로 쓰는 효과 태그 표기를 Fish 정식 표기로 정규화한다({@code [pause]} → {@code [break]} 등).
  • + *
  • 대괄호 태그 자체는 제거하지 않는다 — Fish S2.1 이 인라인으로 해석한다.
  • + *
+ */ +public final class TtsTextNormalizer { + + private static final Map EFFECT_ALIASES = Map.of( + "[soft]", "[soft tone]", + "[pause]", "[break]", + "[long pause]", "[long-break]", + "[long-pause]", "[long-break]" + ); + + private TtsTextNormalizer() { + } + + public static String normalize(String rawText, Tone tone) { + String text = normalizeEffectTags(rawText); + + if (tone != null && tone.hasTag()) { + text = tone.getTag() + " " + text.stripLeading(); + } + return text.trim(); + } + + /** 효과 태그 표기만 Fish 정식 표기로 바꾼다(톤 태그는 안 붙임). 파서가 저장 텍스트를 정리할 때 쓴다. */ + public static String normalizeEffectTags(String rawText) { + String text = rawText == null ? "" : rawText; + for (Map.Entry alias : EFFECT_ALIASES.entrySet()) { + text = text.replaceAll("(?i)" + java.util.regex.Pattern.quote(alias.getKey()), alias.getValue()); + } + return text; + } +} diff --git a/src/main/java/com/swyp/picke/global/web/controller/StaticTextFileController.java b/src/main/java/com/swyp/picke/global/web/controller/StaticTextFileController.java new file mode 100644 index 00000000..2b2a6925 --- /dev/null +++ b/src/main/java/com/swyp/picke/global/web/controller/StaticTextFileController.java @@ -0,0 +1,67 @@ +package com.swyp.picke.global.web.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.io.IOException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * app-ads.txt, robots.txt는 크롤러(AdMob, 검색엔진)가 Content-Type: text/plain을 기대하므로, + * 정적 리소스 핸들러의 MIME 추론에 맡기지 않고 명시적으로 text/plain으로 응답한다. + * 이용약관/개인정보처리방침은 노션 공유 링크가 로그인 리다이렉트 루프에 걸려 외부(AdMob 심사 등)에서 + * 접근 불가능해서, 인증 없이 접근 가능한 정적 HTML로 직접 서빙한다. + */ +@Tag(name = "정적 텍스트 파일", description = "app-ads.txt, robots.txt, 약관/정책 페이지 서빙") +@RestController +public class StaticTextFileController { + + private static final MediaType TEXT_PLAIN_UTF8 = new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8); + + @Operation(summary = "app-ads.txt 조회") + @GetMapping("/app-ads.txt") + public ResponseEntity getAppAdsTxt() throws IOException { + return textResponse("static/app-ads.txt"); + } + + @Operation(summary = "robots.txt 조회") + @GetMapping("/robots.txt") + public ResponseEntity getRobotsTxt() throws IOException { + return textResponse("static/robots.txt"); + } + + @Operation(summary = "서비스 이용약관 조회") + @GetMapping("/terms") + public ResponseEntity getTerms() throws IOException { + return htmlResponse("static/terms.html"); + } + + @Operation(summary = "개인정보 처리방침 조회") + @GetMapping("/privacy-policy") + public ResponseEntity getPrivacyPolicy() throws IOException { + return htmlResponse("static/privacy-policy.html"); + } + + private ResponseEntity textResponse(String classpathLocation) throws IOException { + Resource resource = new ClassPathResource(classpathLocation); + return ResponseEntity.ok() + .contentType(TEXT_PLAIN_UTF8) + .contentLength(resource.contentLength()) + .header(HttpHeaders.CACHE_CONTROL, "public, max-age=3600") + .body(resource); + } + + private ResponseEntity htmlResponse(String classpathLocation) throws IOException { + Resource resource = new ClassPathResource(classpathLocation); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_HTML) + .contentLength(resource.contentLength()) + .header(HttpHeaders.CACHE_CONTROL, "public, max-age=3600") + .body(resource); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e2671442..5b8ca136 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,6 +32,8 @@ spring: aws: s3: bucket: ${AWS_S3_BUCKET} + endpoint: ${AWS_ENDPOINT_URL:} + path-style-access-enabled: ${S3_PATH_STYLE_ACCESS_ENABLED:false} region: static: ${AWS_REGION} credentials: @@ -81,6 +83,8 @@ admob: android: ${ADMOB_REWARD_UNIT_ID_ANDROID} openai: + emotion: + model: ${OPENAI_EMOTION_MODEL:gpt-4o-mini} api-key: ${OPENAI_API_KEY} url: https://api.openai.com/v1/chat/completions model: gpt-4o-mini @@ -92,6 +96,7 @@ fishaudio: api-key: ${FISHAUDIO_API_KEY} tts: url: https://api.fish.audio/v1/tts + model: ${FISHAUDIO_TTS_MODEL:s2.1-pro} voice-id: a: ${FISHAUDIO_VOICE_A} b: ${FISHAUDIO_VOICE_B} @@ -124,8 +129,27 @@ jwt: access-token-expiration: 3600000 refresh-token-expiration: 1209600000 +admin: + web: + url: ${ADMIN_WEB_URL:https://picke.up.railway.app} + picke: baseUrl: ${PICKE_BASE_URL:https://picke.store} + ad: + host: ${AD_HOST:ad.picke.store} + base-url: ${AD_BASE_URL:https://ad.picke.store} + adpick: + # 애드픽 파트너센터 링크생성 화면에서 규격 확인 후 채운다. 비어 있으면 원본 링크를 그대로 넘긴다. + sub-id-param: ${ADPICK_SUB_ID_PARAM:} + # 애드픽 회원 아이디. 비어 있으면 캠페인 동기화를 건너뛴다. + aff-id: ${ADPICK_AFF_ID:} + base-url: ${ADPICK_BASE_URL:https://adpick.co.kr/apis/offers.php} + # 앱 설치형이 많아 세션이 끝나는 지면에 둔다. + slot: ${ADPICK_SLOT:BATTLE_RESULT_BOTTOM} + cta-text: ${ADPICK_CTA_TEXT:설치하고 받기} + # 애드픽 가이드가 최대 1분 1회 이하 호출을 요구한다. + sync-initial-delay-ms: ${ADPICK_SYNC_INITIAL_DELAY_MS:60000} + sync-interval-ms: ${ADPICK_SYNC_INTERVAL_MS:600000} s3: presigned-url: expiration-hours: 6 @@ -136,4 +160,9 @@ media: ffmpeg: path: ${FFMPEG_PATH:ffmpeg} ffprobe: - path: ${FFPROBE_PATH:ffprobe} \ No newline at end of file + path: ${FFPROBE_PATH:ffprobe} + +coupang: + partners: + # 제휴 링크에 lptag로 노출되는 공개 식별자다. 소재 등록 시 남의 링크가 아닌지 대조하는 데 쓴다. + id: ${COUPANG_PARTNERS_ID:} diff --git a/src/main/resources/db/migration/V20260724_01__create_user_local_accounts.sql b/src/main/resources/db/migration/V20260724_01__create_user_local_accounts.sql new file mode 100644 index 00000000..7a81529e --- /dev/null +++ b/src/main/resources/db/migration/V20260724_01__create_user_local_accounts.sql @@ -0,0 +1,12 @@ +-- 관리자가 발급하는 테스트 계정(username/password) 로컬 로그인을 위한 테이블 +CREATE TABLE IF NOT EXISTS user_local_accounts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_user_local_accounts_username UNIQUE (username), + CONSTRAINT uk_user_local_accounts_user_id UNIQUE (user_id), + CONSTRAINT fk_user_local_accounts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql b/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql new file mode 100644 index 00000000..70990822 --- /dev/null +++ b/src/main/resources/db/migration/V20260724_02__drop_perspectives_unique_constraint.sql @@ -0,0 +1,20 @@ +-- 1인 1관점 제한 해제: perspectives(battle_id, user_id) 유니크 제약 제거 +-- Hibernate가 이름을 자동 생성해 환경마다 다를 수 있으므로 이름에 의존하지 않고 동적으로 찾아 드롭한다. +DO $$ +DECLARE + found_constraint_name text; +BEGIN + SELECT tc.constraint_name INTO found_constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + WHERE tc.table_name = 'perspectives' + AND tc.constraint_type = 'UNIQUE' + GROUP BY tc.constraint_name + HAVING array_agg(kcu.column_name ORDER BY kcu.column_name) = ARRAY['battle_id', 'user_id']; + + IF found_constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE perspectives DROP CONSTRAINT %I', found_constraint_name); + END IF; +END $$; diff --git a/src/main/resources/db/migration/V20260808_01__create_notification_schedules.sql b/src/main/resources/db/migration/V20260808_01__create_notification_schedules.sql new file mode 100644 index 00000000..d1ba5028 --- /dev/null +++ b/src/main/resources/db/migration/V20260808_01__create_notification_schedules.sql @@ -0,0 +1,11 @@ +-- 관리자가 설정한 매일 정해진 시각에 자동 발송되는 예약 푸시알림 테이블 +CREATE TABLE IF NOT EXISTS notification_schedules ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(150) NOT NULL, + subtitle VARCHAR(150) NOT NULL, + send_time TIME NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_sent_date DATE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); diff --git a/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql b/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql new file mode 100644 index 00000000..717c9117 --- /dev/null +++ b/src/main/resources/db/migration/V20260826_01__add_deleted_at_to_notifications.sql @@ -0,0 +1,3 @@ +-- 관리자 공지사항 soft delete를 위한 deleted_at 컬럼 추가 +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP; diff --git a/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql b/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql new file mode 100644 index 00000000..6d754e67 --- /dev/null +++ b/src/main/resources/db/migration/V20260826_02__create_notification_delivery_results.sql @@ -0,0 +1,11 @@ +-- 관리자 공지/예약 알림 발송 결과(대상/성공/실패 건수) 집계 테이블 +CREATE TABLE IF NOT EXISTS notification_delivery_results ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + notification_id BIGINT NOT NULL, + target_count INT NOT NULL, + success_count INT NOT NULL DEFAULT 0, + failure_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_notification_delivery_results_notification_id UNIQUE (notification_id) +); diff --git a/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql new file mode 100644 index 00000000..daa6c787 --- /dev/null +++ b/src/main/resources/db/migration/V20260830_01__create_user_daily_activities.sql @@ -0,0 +1,11 @@ +-- 어드민 대시보드 DAU/MAU 집계를 위한 유저-날짜별 활동 기록 테이블 +CREATE TABLE IF NOT EXISTS user_daily_activities ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + activity_date DATE NOT NULL, + logged_in BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP, + updated_at TIMESTAMP, + CONSTRAINT uk_user_daily_activities_user_date UNIQUE (user_id, activity_date) +); diff --git a/src/main/resources/frontend/ts/admin/picke.ts b/src/main/resources/frontend/ts/admin/picke.ts deleted file mode 100644 index 16a91a0c..00000000 --- a/src/main/resources/frontend/ts/admin/picke.ts +++ /dev/null @@ -1,101 +0,0 @@ -type ContentType = 'battle' | 'quiz' | 'vote'; - -interface BaseContent { - type: ContentType; - title: string; - category?: string[]; - hint?: string; -} - -interface BattleContent extends BaseContent { - characterA: { name: string; position: string; desc: string }; - characterB: { name: string; position: string; desc: string }; - scripts: any[]; -} - -interface QuizContent extends BaseContent { - question: string; - answerO: string; - answerX: string; -} - -document.addEventListener("DOMContentLoaded", () => { - let currentTab: ContentType = 'battle'; - - const tabButtons = document.querySelectorAll('#tab-container button'); - const contentTitleInput = document.getElementById('content-title') as HTMLInputElement | null; - const previewTitle = document.getElementById('preview-title') as HTMLElement | null; - const submitBtn = document.getElementById('btn-submit') as HTMLButtonElement | null; - - tabButtons.forEach(btn => { - btn.addEventListener('click', function () { - const tabName = this.dataset.tab as ContentType; - if (tabName) switchTab(tabName); - }); - }); - - if (contentTitleInput) { - contentTitleInput.addEventListener('input', (e: Event) => { - const target = e.target as HTMLInputElement; - if (previewTitle) previewTitle.innerHTML = target.value || '제목 없음'; - }); - } - - if (submitBtn) { - submitBtn.addEventListener('click', submitContent); - } - - function switchTab(tabName: ContentType): void { - currentTab = tabName; - - tabButtons.forEach(btn => { - btn.className = btn.dataset.tab === tabName - ? "px-6 py-2 rounded-full text-sm font-bold bg-black text-white transition-all" - : "px-6 py-2 rounded-full text-sm font-bold text-gray-500 hover:text-black transition-all"; - }); - - ['battle', 'quiz', 'vote'].forEach(t => { - const form = document.getElementById(`form-${t}`) as HTMLElement | null; - if (form) { - form.classList.toggle('hidden', t !== tabName); - form.classList.toggle('block', t === tabName); - } - }); - - updatePreview(tabName); - } - - function updatePreview(tabName: ContentType): void { - const badge = document.getElementById('preview-badge') as HTMLElement | null; - const battleIntro = document.getElementById('preview-battle-intro') as HTMLElement | null; - const quizView = document.getElementById('preview-quiz-view') as HTMLElement | null; - - if (battleIntro) battleIntro.classList.add('hidden'); - if (quizView) quizView.classList.add('hidden'); - - if (badge) { - badge.innerText = `${tabName.toUpperCase()} MODE`; - const color = tabName === 'battle' ? 'purple' : tabName === 'quiz' ? 'blue' : 'green'; - badge.className = `px-2 py-1 bg-${color}-100 text-${color}-700 text-[10px] font-bold rounded`; - } - - if (tabName === 'battle' && battleIntro) battleIntro.classList.remove('hidden'); - if (tabName === 'quiz' && quizView) quizView.classList.remove('hidden'); - } - - function submitContent(): void { - const title = contentTitleInput?.value || ''; - if (!title.trim()) { - alert("제목을 입력해주세요."); - contentTitleInput?.focus(); - return; - } - - // 현재 탭에 따른 데이터 수집 - let payload: any = { type: currentTab, title: title }; - // TODO: 여기서 폼의 각 input 값을 payload에 담아줍니다. - - console.log("서버로 전송할 데이터:", payload); - alert(`[${currentTab.toUpperCase()}] 콘텐츠가 콘솔에 출력되었습니다. API 연동을 진행해주세요.`); - } -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/api/api-common.js b/src/main/resources/static/js/admin/api/api-common.js deleted file mode 100644 index 5596d2db..00000000 --- a/src/main/resources/static/js/admin/api/api-common.js +++ /dev/null @@ -1,135 +0,0 @@ -PickeData.existingUrls = { thumbnail: null, charA: null, charB: null }; -PickeData.scenarioId = null; - -window.applyThumbnailPreview = function(url) { - if (!url) return; - - const bgIds = ['thumbnail-preview-bg']; - const placeholderIds = ['thumbnail-placeholder']; - const previewIds = ['intro-bg-img']; - - bgIds.forEach((id) => { - const bg = document.getElementById(id); - if (!bg) return; - bg.style.backgroundImage = `url('${url}')`; - bg.style.setProperty('opacity', '1', 'important'); - bg.classList.remove('opacity-0'); - }); - - placeholderIds.forEach((id) => { - const placeholder = document.getElementById(id); - if (placeholder) { - placeholder.style.display = 'none'; - placeholder.classList.add('hidden'); - } - }); - - previewIds.forEach((id) => { - const target = document.getElementById(id); - if (target) target.style.backgroundImage = `url('${url}')`; - }); -}; - -window.setTargetDateInputs = function(dateValue) { - if (!dateValue) return; - ['battle-target-date', 'quiz-target-date', 'poll-target-date'].forEach((id) => { - const input = document.getElementById(id); - if (input) input.value = dateValue; - }); -}; - -window.uploadImageToServer = async function(file, category) { - if (!file) return null; - const formData = new FormData(); - formData.append('file', file); - formData.append('category', category); - - try { - const res = await fetch(PickeData.API.FILE_UPLOAD, { - method: 'POST', - headers: { 'Authorization': `Bearer ${PickeData.token}` }, - body: formData - }); - if (!res.ok) throw new Error(res.status); - const text = await res.text(); - try { return JSON.parse(text).result ?? JSON.parse(text).data ?? text; } - catch { return text; } - } catch (e) { - console.error("이미지 업로드 실패:", e); - return null; - } -}; - -window.uploadImageToLocalDraft = async function(file) { - if (!file) return null; - const formData = new FormData(); - formData.append('file', file); - - try { - const res = await fetch(PickeData.API.FILE_UPLOAD_LOCAL, { - method: 'POST', - headers: { 'Authorization': `Bearer ${PickeData.token}` }, - body: formData - }); - if (!res.ok) throw new Error(res.status); - const text = await res.text(); - try { return JSON.parse(text).result ?? JSON.parse(text).data ?? text; } - catch { return text; } - } catch (e) { - console.error("로컬 임시 이미지 업로드 실패:", e); - return null; - } -}; - -document.addEventListener("DOMContentLoaded", async () => { - function setupImageUpload(inputId, bgId, placeholderId, targetImgId, fileKey) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - PickeData.uploadedFiles[fileKey] = file; - const url = URL.createObjectURL(file); - PickeData.setPreviewImage(bgId, placeholderId, targetImgId, url); - if (window.updateChatPreview) window.updateChatPreview(); - }); - } - - function setupThumbnailUpload(inputId) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - PickeData.uploadedFiles.thumbnail = file; - const url = URL.createObjectURL(file); - window.applyThumbnailPreview(url); - }); - } - - function bindTargetDateInput(inputId) { - const input = document.getElementById(inputId); - if (!input) return; - input.addEventListener('change', (e) => { - const value = e.target.value; - if (!value) return; - PickeData.currentTargetDate = value; - window.setTargetDateInputs(value); - }); - } - - setupThumbnailUpload('thumbnail-upload'); - setupImageUpload('char-a-img-upload', 'char-a-img-bg', 'char-a-img-placeholder', 'intro-char-a-img', 'charA'); - setupImageUpload('char-b-img-upload', 'char-b-img-bg', 'char-b-img-placeholder', 'intro-char-b-img', 'charB'); - - bindTargetDateInput('battle-target-date'); - bindTargetDateInput('quiz-target-date'); - bindTargetDateInput('poll-target-date'); - - const today = new Date().toISOString().split('T')[0]; - if (!PickeData.currentTargetDate) PickeData.currentTargetDate = today; - window.setTargetDateInputs(PickeData.currentTargetDate); - - if (typeof window.fetchAllTags === 'function') await window.fetchAllTags(); - if (typeof window.loadContent === 'function') await window.loadContent(); -}); diff --git a/src/main/resources/static/js/admin/api/api-load.js b/src/main/resources/static/js/admin/api/api-load.js deleted file mode 100644 index 9d3e4e6a..00000000 --- a/src/main/resources/static/js/admin/api/api-load.js +++ /dev/null @@ -1,309 +0,0 @@ -window.loadContent = async function () { - if (!PickeData.isEditMode) return; - - const type = PickeData.currentTypeParam === 'VOTE' ? 'POLL' : PickeData.currentTypeParam; - const endpointByType = { - BATTLE: PickeData.API.BATTLE_GET, - QUIZ: PickeData.API.QUIZ_GET, - POLL: PickeData.API.POLL_GET - }; - - const formTargetByType = { - BATTLE: 'form-battle', - QUIZ: 'form-quiz', - POLL: 'form-vote' - }; - - const createEmptyScript = (speakerType = 'NARRATOR') => ({ speakerType, text: '' }); - - const renderScenarioScripts = (containerId, scripts, defaultSpeaker = 'NARRATOR') => { - const container = document.getElementById(containerId); - if (!container) return; - - container.innerHTML = ''; - const safeScripts = Array.isArray(scripts) && scripts.length > 0 - ? scripts - : [createEmptyScript(defaultSpeaker)]; - - safeScripts.forEach((script) => { - if (typeof window.addScriptBlock === 'function') { - window.addScriptBlock(containerId, script.speakerType || defaultSpeaker); - } else { - return; - } - - const blocks = container.querySelectorAll('.script-block'); - const block = blocks[blocks.length - 1]; - if (!block) return; - - const speakerSelect = block.querySelector('.speaker-select'); - if (speakerSelect) { - speakerSelect.value = script.speakerType || defaultSpeaker; - } - - const scriptText = block.querySelector('.script-text'); - if (scriptText) { - scriptText.value = script.text || ''; - scriptText.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - }; - - const applyScenarioToForm = (scenario) => { - if (!scenario) return; - - const nodes = Array.isArray(scenario.nodes) ? scenario.nodes : []; - const nodeByName = {}; - const nodeNameById = {}; - - nodes.forEach((node) => { - if (!node) return; - nodeByName[node.nodeName] = node; - if (node.nodeId != null) { - nodeNameById[node.nodeId] = node.nodeName; - } - }); - - const startNode = nodeByName.START || null; - const branchANode = nodeByName.BRANCH_A || null; - const branchBNode = nodeByName.BRANCH_B || null; - const closingNode = nodeByName.CLOSING || null; - const isInteractive = !!scenario.isInteractive; - - const branchContainer = document.getElementById('branch-container'); - const addBranchButton = document.getElementById('btn-add-branch'); - if (branchContainer) { - if (isInteractive) branchContainer.classList.remove('hidden'); - else branchContainer.classList.add('hidden'); - } - if (addBranchButton) { - if (isInteractive) addBranchButton.classList.add('hidden'); - else addBranchButton.classList.remove('hidden'); - } - - renderScenarioScripts('start-node-container', startNode?.scripts || [], 'NARRATOR'); - renderScenarioScripts('closing-node-container', closingNode?.scripts || [], 'NARRATOR'); - renderScenarioScripts('branch-a-node-container', branchANode?.scripts || [], 'A'); - renderScenarioScripts('branch-b-node-container', branchBNode?.scripts || [], 'B'); - - const branchAInput = document.getElementById('branch-a-label'); - const branchBInput = document.getElementById('branch-b-label'); - - const startOptions = Array.isArray(startNode?.interactiveOptions) ? startNode.interactiveOptions : []; - let branchALabel = ''; - let branchBLabel = ''; - - startOptions.forEach((option) => { - const targetNodeName = option?.nextNodeId != null ? nodeNameById[option.nextNodeId] : null; - if (targetNodeName === 'BRANCH_A') branchALabel = option.label || ''; - if (targetNodeName === 'BRANCH_B') branchBLabel = option.label || ''; - }); - - if (branchAInput) branchAInput.value = branchALabel; - if (branchBInput) branchBInput.value = branchBLabel; - - if (window.updateChatPreview) window.updateChatPreview(); - }; - - try { - const getter = endpointByType[type] || PickeData.API.BATTLE_GET; - const res = await fetch(getter(PickeData.currentContentId), { headers: PickeData.getAuthHeaders() }); - if (!res.ok) return; - - const json = await res.json(); - const data = json.result || json.data || json; - const toDatetimeLocal = (value) => value ? String(value).slice(0, 16) : ''; - - document.querySelector(`[data-target="${formTargetByType[type]}"]`)?.click(); - PickeData.currentContentType = type; - - Object.keys(PickeData.selections).forEach((key) => { - PickeData.selections[key] = []; - }); - - PickeData.currentTargetDate = data.targetDate || PickeData.currentTargetDate; - PickeData.currentStatus = data.status || 'PENDING'; - if (window.setTargetDateInputs) window.setTargetDateInputs(PickeData.currentTargetDate); - - if (type === 'BATTLE') { - PickeData.setValue('content-title', data.title || ''); - PickeData.setValue('content-summary', data.summary || ''); - PickeData.setValue('content-desc', data.description || ''); - PickeData.setValue('battle-audio-duration', data.audioDuration ?? ''); - PickeData.setValue('battle-status', data.status || 'PENDING'); - PickeData.setValue('battle-publish-at', toDatetimeLocal(data.publishAt)); - PickeData.setValue('battle-thumbnail-url', data.thumbnailUrl || ''); - - if (data.thumbnailUrl) { - PickeData.existingUrls.thumbnail = data.thumbnailUrl; - if (window.applyThumbnailPreview) window.applyThumbnailPreview(data.thumbnailUrl); - } - - PickeData.selections.CATEGORY = (data.tags || []).map((tag) => tag.tagId || tag.id); - - const options = data.options || []; - const optionA = options.find((option) => option.label === 'A'); - const optionB = options.find((option) => option.label === 'B'); - - if (optionA) { - PickeData.setValue('char-a-title', optionA.title || ''); - PickeData.setValue('char-a-stance', optionA.stance || ''); - PickeData.setValue('char-a-rep', optionA.representative || ''); - PickeData.setValue('char-a-display-order', optionA.displayOrder ?? 1); - PickeData.setValue('char-a-image-url', optionA.imageUrl || ''); - if (optionA.imageUrl) { - PickeData.existingUrls.charA = optionA.imageUrl; - PickeData.setPreviewImage('char-a-img-bg', 'char-a-img-placeholder', 'intro-char-a-img', optionA.imageUrl); - } - PickeData.selections.BATTLE_A_PHILOSOPHER = (optionA.tags || []) - .filter((tag) => tag.type === 'PHILOSOPHER') - .map((tag) => tag.tagId || tag.id); - PickeData.selections.BATTLE_A_VALUE = (optionA.tags || []) - .filter((tag) => tag.type === 'VALUE') - .map((tag) => tag.tagId || tag.id); - } - - if (optionB) { - PickeData.setValue('char-b-title', optionB.title || ''); - PickeData.setValue('char-b-stance', optionB.stance || ''); - PickeData.setValue('char-b-rep', optionB.representative || ''); - PickeData.setValue('char-b-display-order', optionB.displayOrder ?? 2); - PickeData.setValue('char-b-image-url', optionB.imageUrl || ''); - if (optionB.imageUrl) { - PickeData.existingUrls.charB = optionB.imageUrl; - PickeData.setPreviewImage('char-b-img-bg', 'char-b-img-placeholder', 'intro-char-b-img', optionB.imageUrl); - } - PickeData.selections.BATTLE_B_PHILOSOPHER = (optionB.tags || []) - .filter((tag) => tag.type === 'PHILOSOPHER') - .map((tag) => tag.tagId || tag.id); - PickeData.selections.BATTLE_B_VALUE = (optionB.tags || []) - .filter((tag) => tag.type === 'VALUE') - .map((tag) => tag.tagId || tag.id); - } - - try { - const scenRes = await fetch(`/api/v1/admin/battles/${PickeData.currentContentId}/scenario`, { headers: PickeData.getAuthHeaders() }); - if (scenRes.ok) { - const scenJson = await scenRes.json(); - const scenario = scenJson.result || scenJson.data; - if (scenario) { - const voiceSettings = scenario.voiceSettings || {}; - PickeData.setValue('tts-voice-narrator', voiceSettings.NARRATOR || ''); - PickeData.setValue('tts-voice-a', voiceSettings.A || ''); - PickeData.setValue('tts-voice-b', voiceSettings.B || ''); - PickeData.setValue('tts-voice-user', voiceSettings.USER || ''); - PickeData.scenarioId = scenario.scenarioId || scenario.id || null; - applyScenarioToForm(scenario); - } - } - } catch (e) { - console.error('시나리오를 불러오지 못했습니다:', e); - } - } - - if (type === 'QUIZ') { - PickeData.setValue('quiz-title', data.title || ''); - PickeData.setValue('quiz-status', data.status || 'PENDING'); - PickeData.setValue('quiz-publish-at', toDatetimeLocal(data.publishAt)); - - const options = data.options || []; - const optionA = options.find((option) => option.label === 'A'); - const optionB = options.find((option) => option.label === 'B'); - - if (optionA) { - PickeData.setValue('quiz-option-a-title', optionA.text || ''); - PickeData.setValue('quiz-option-a-detail', optionA.detailText || ''); - PickeData.setValue('quiz-option-a-display-order', optionA.displayOrder ?? 1); - if (optionA.isCorrect) document.getElementById('quiz-answer-a').checked = true; - } - - if (optionB) { - PickeData.setValue('quiz-option-b-title', optionB.text || ''); - PickeData.setValue('quiz-option-b-detail', optionB.detailText || ''); - PickeData.setValue('quiz-option-b-display-order', optionB.displayOrder ?? 2); - if (optionB.isCorrect) document.getElementById('quiz-answer-b').checked = true; - } - } - - if (type === 'POLL') { - PickeData.setValue('poll-title-prefix', data.titlePrefix || ''); - PickeData.setValue('poll-title-suffix', data.titleSuffix || ''); - PickeData.setValue('poll-status', data.status || 'PENDING'); - PickeData.setValue('poll-publish-at', toDatetimeLocal(data.publishAt)); - - const optionTargetByLabel = { - A: { titleId: 'poll-option-1-title', orderId: 'poll-option-1-display-order' }, - B: { titleId: 'poll-option-2-title', orderId: 'poll-option-2-display-order' }, - C: { titleId: 'poll-option-3-title', orderId: 'poll-option-3-display-order' }, - D: { titleId: 'poll-option-4-title', orderId: 'poll-option-4-display-order' } - }; - - (data.options || []).forEach((option) => { - const mapping = optionTargetByLabel[option.label]; - if (!mapping) return; - PickeData.setValue(mapping.titleId, option.title || ''); - PickeData.setValue(mapping.orderId, option.displayOrder ?? null); - }); - } - - if (window.refreshFormBadges) window.refreshFormBadges(); - if (window.updatePreviewTags) window.updatePreviewTags(); - - document.querySelectorAll('textarea, input').forEach((el) => { - el.dispatchEvent(new Event('input', { bubbles: true })); - }); - - if (window.updateChatPreview) window.updateChatPreview(); - if (window.updateButtonStates) window.updateButtonStates(data.status); - } catch (e) { - console.error('콘텐츠를 불러오는 중 오류가 발생했습니다:', e); - } -}; - -window.updateButtonStates = function (currentStatus) { - const btnPending = document.getElementById('btn-save-pending'); - const btnScheduled = document.getElementById('btn-save-scheduled'); - const btnPublish = document.getElementById('btn-save-publish'); - const btnRepublish = document.getElementById('btn-republish-audio'); - - if (PickeData.isEditMode && btnPublish) { - if (currentStatus === 'PUBLISHED') { - btnPublish.innerText = '수정 저장 (텍스트만)'; - btnPublish.onclick = () => window.saveContent('EDIT'); - } else { - btnPublish.innerText = '발행'; - btnPublish.onclick = () => window.saveContent('PUBLISHED'); - } - } - - if (currentStatus === 'PUBLISHED') { - if (btnPending) { - btnPending.disabled = true; - btnPending.classList.add('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnScheduled) { - btnScheduled.disabled = true; - btnScheduled.classList.add('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnRepublish && PickeData.currentContentType === 'BATTLE') { - btnRepublish.classList.remove('hidden'); - } - } else { - if (btnRepublish) btnRepublish.classList.add('hidden'); - if (btnPending) { - btnPending.disabled = false; - btnPending.classList.remove('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - if (btnScheduled) { - btnScheduled.disabled = false; - btnScheduled.classList.remove('opacity-50', 'cursor-not-allowed', 'bg-gray-200', 'text-gray-400'); - } - } -}; - -window.confirmRepublish = function () { - const isConfirmed = confirm('시나리오 오디오를 다시 생성할까요? TTS 사용량이 증가할 수 있습니다.'); - if (isConfirmed) { - window.saveContent('PUBLISH'); - } -}; diff --git a/src/main/resources/static/js/admin/api/api-save.js b/src/main/resources/static/js/admin/api/api-save.js deleted file mode 100644 index 909fffb3..00000000 --- a/src/main/resources/static/js/admin/api/api-save.js +++ /dev/null @@ -1,372 +0,0 @@ -window.saveContent = async (action) => { - const loader = document.getElementById('global-loader'); - const loaderText = document.getElementById('loader-text'); - - const statusFromAction = action === 'PENDING' || action === 'SCHEDULED' ? 'PENDING' : 'PUBLISHED'; - - if (loader) { - if (loaderText) { - if (action === 'PUBLISHED' || action === 'PUBLISH') loaderText.innerText = '콘텐츠를 발행하는 중입니다...'; - else if (action === 'SCHEDULED') loaderText.innerText = '콘텐츠를 예약 저장 중입니다...'; - else if (action === 'EDIT') loaderText.innerText = '콘텐츠를 수정하는 중입니다...'; - else loaderText.innerText = '임시 저장 중입니다...'; - } - loader.classList.remove('hidden'); - loader.classList.add('flex'); - } - - const toUrlString = (urlObj) => { - if (!urlObj) return null; - if (typeof urlObj === 'string') return urlObj; - return urlObj.s3Key || urlObj.presignedUrl || String(urlObj); - }; - - const asIntOrNull = (value) => { - if (value == null || value === '') return null; - const parsed = Number(value); - return Number.isNaN(parsed) ? null : parsed; - }; - - const setHiddenImageValue = (id, value) => { - const input = document.getElementById(id); - if (input) input.value = value || ''; - }; - - const getTargetDate = (type) => { - const inputIdByType = { - BATTLE: 'battle-target-date', - QUIZ: 'quiz-target-date', - POLL: 'poll-target-date' - }; - return document.getElementById(inputIdByType[type])?.value || PickeData.currentTargetDate || new Date().toISOString().split('T')[0]; - }; - - const getPublishAt = (type) => { - const inputIdByType = { - BATTLE: 'battle-publish-at', - QUIZ: 'quiz-publish-at', - POLL: 'poll-publish-at' - }; - const value = document.getElementById(inputIdByType[type])?.value; - return value ? value : null; - }; - - const getStatus = (type) => { - if (action === 'PENDING' || action === 'SCHEDULED') return 'PENDING'; - if (action === 'PUBLISHED' || action === 'PUBLISH') return 'PUBLISHED'; - - const statusInputByType = { - BATTLE: 'battle-status', - QUIZ: 'quiz-status', - POLL: 'poll-status' - }; - return document.getElementById(statusInputByType[type])?.value || statusFromAction; - }; - - try { - const currentType = PickeData.currentContentType; - const previousStatus = PickeData.currentStatus; - const targetDate = getTargetDate(currentType); - const publishAt = getPublishAt(currentType); - const resolvedStatus = getStatus(currentType); - const hasNewBattleImageUploads = currentType === 'BATTLE' - && !!(PickeData.uploadedFiles.thumbnail || PickeData.uploadedFiles.charA || PickeData.uploadedFiles.charB); - const shouldUploadAssets = action === 'PUBLISHED' || action === 'PUBLISH' || (action === 'EDIT' && hasNewBattleImageUploads); - const shouldUploadLocalDraft = action === 'PENDING' || action === 'SCHEDULED'; - - PickeData.currentTargetDate = targetDate; - - if (action === 'SCHEDULED' && !publishAt) { - throw new Error('예약 발행 시각을 입력해 주세요.'); - } - - let thumbnailUrl = PickeData.existingUrls.thumbnail; - let charAUrl = PickeData.existingUrls.charA; - let charBUrl = PickeData.existingUrls.charB; - - if (currentType === 'BATTLE') { - const uploadByAction = async (file, category) => { - if (!file) return null; - if (shouldUploadAssets) return window.uploadImageToServer(file, category); - if (shouldUploadLocalDraft) return window.uploadImageToLocalDraft(file); - return null; - }; - - if (PickeData.uploadedFiles.thumbnail) { - thumbnailUrl = await uploadByAction(PickeData.uploadedFiles.thumbnail, 'BATTLE'); - } - if (PickeData.uploadedFiles.charA) { - charAUrl = await uploadByAction(PickeData.uploadedFiles.charA, 'PHILOSOPHER'); - } - if (PickeData.uploadedFiles.charB) { - charBUrl = await uploadByAction(PickeData.uploadedFiles.charB, 'PHILOSOPHER'); - } - } - - thumbnailUrl = toUrlString(thumbnailUrl); - charAUrl = toUrlString(charAUrl); - charBUrl = toUrlString(charBUrl); - - PickeData.existingUrls.thumbnail = thumbnailUrl || null; - PickeData.existingUrls.charA = charAUrl || null; - PickeData.existingUrls.charB = charBUrl || null; - - setHiddenImageValue('battle-thumbnail-url', thumbnailUrl); - setHiddenImageValue('char-a-image-url', charAUrl); - setHiddenImageValue('char-b-image-url', charBUrl); - - let payload = null; - let requestUrl = ''; - - if (currentType === 'BATTLE') { - payload = { - status: resolvedStatus, - title: document.getElementById('content-title')?.value || '', - summary: document.getElementById('content-summary')?.value || '', - description: document.getElementById('content-desc')?.value || '', - thumbnailUrl: thumbnailUrl || document.getElementById('battle-thumbnail-url')?.value || null, - targetDate, - publishAt, - audioDuration: asIntOrNull(document.getElementById('battle-audio-duration')?.value), - tagIds: PickeData.selections.CATEGORY || [], - options: [ - { - title: document.getElementById('char-a-title')?.value || '', - stance: document.getElementById('char-a-stance')?.value || '', - representative: document.getElementById('char-a-rep')?.value || '', - imageUrl: charAUrl || document.getElementById('char-a-image-url')?.value || null, - displayOrder: asIntOrNull(document.getElementById('char-a-display-order')?.value) || 1, - tagIds: [ - ...(PickeData.selections.BATTLE_A_PHILOSOPHER || []), - ...(PickeData.selections.BATTLE_A_VALUE || []) - ] - }, - { - title: document.getElementById('char-b-title')?.value || '', - stance: document.getElementById('char-b-stance')?.value || '', - representative: document.getElementById('char-b-rep')?.value || '', - imageUrl: charBUrl || document.getElementById('char-b-image-url')?.value || null, - displayOrder: asIntOrNull(document.getElementById('char-b-display-order')?.value) || 2, - tagIds: [ - ...(PickeData.selections.BATTLE_B_PHILOSOPHER || []), - ...(PickeData.selections.BATTLE_B_VALUE || []) - ] - } - ] - }; - requestUrl = PickeData.isEditMode ? PickeData.API.BATTLE_UPDATE(PickeData.currentContentId) : PickeData.API.BATTLE_CREATE; - } else if (currentType === 'QUIZ') { - payload = { - title: document.getElementById('quiz-title')?.value || '', - targetDate, - publishAt, - status: resolvedStatus, - options: [ - { - label: 'A', - text: document.getElementById('quiz-option-a-title')?.value || '', - detailText: document.getElementById('quiz-option-a-detail')?.value || '', - isCorrect: document.getElementById('quiz-answer-a')?.checked || false, - displayOrder: asIntOrNull(document.getElementById('quiz-option-a-display-order')?.value) || 1 - }, - { - label: 'B', - text: document.getElementById('quiz-option-b-title')?.value || '', - detailText: document.getElementById('quiz-option-b-detail')?.value || '', - isCorrect: document.getElementById('quiz-answer-b')?.checked || false, - displayOrder: asIntOrNull(document.getElementById('quiz-option-b-display-order')?.value) || 2 - } - ] - }; - requestUrl = PickeData.isEditMode ? PickeData.API.QUIZ_UPDATE(PickeData.currentContentId) : PickeData.API.QUIZ_CREATE; - } else { - const pollOptions = [ - { label: 'A', titleId: 'poll-option-1-title', orderId: 'poll-option-1-display-order' }, - { label: 'B', titleId: 'poll-option-2-title', orderId: 'poll-option-2-display-order' }, - { label: 'C', titleId: 'poll-option-3-title', orderId: 'poll-option-3-display-order' }, - { label: 'D', titleId: 'poll-option-4-title', orderId: 'poll-option-4-display-order' } - ] - .map((option, index) => ({ - label: option.label, - title: document.getElementById(option.titleId)?.value || '', - displayOrder: asIntOrNull(document.getElementById(option.orderId)?.value) || (index + 1) - })) - .filter((option) => option.title.trim().length > 0); - - payload = { - titlePrefix: document.getElementById('poll-title-prefix')?.value || '', - titleSuffix: document.getElementById('poll-title-suffix')?.value || '', - targetDate, - publishAt, - status: resolvedStatus, - options: pollOptions - }; - requestUrl = PickeData.isEditMode ? PickeData.API.POLL_UPDATE(PickeData.currentContentId) : PickeData.API.POLL_CREATE; - } - - const saveRes = await fetch(requestUrl, { - method: PickeData.isEditMode ? 'PATCH' : 'POST', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify(payload) - }); - if (!saveRes.ok) throw new Error('콘텐츠 저장에 실패했습니다.'); - - const saved = await saveRes.json(); - const result = saved.result || saved.data || {}; - const savedId = result.battleId || result.quizId || result.pollId || result.id || PickeData.currentContentId; - - if (!PickeData.isEditMode) { - PickeData.currentContentId = savedId; - PickeData.isEditMode = true; - } - - if (currentType === 'BATTLE') { - const isInteractive = !document.getElementById('branch-container')?.classList.contains('hidden'); - - const extractScripts = (containerId) => { - const scripts = []; - document.querySelectorAll(`#${containerId} .script-block`).forEach((block) => { - const speakerSelect = block.querySelector('.speaker-select'); - const scriptTextArea = block.querySelector('.script-text'); - if (!speakerSelect || !scriptTextArea) return; - - const speakerType = speakerSelect.value; - let speakerName = 'NARRATOR'; - if (speakerType === 'A') speakerName = document.getElementById('char-a-rep')?.value || 'A'; - if (speakerType === 'B') speakerName = document.getElementById('char-b-rep')?.value || 'B'; - scripts.push({ speakerType, speakerName, text: scriptTextArea.value }); - }); - return scripts; - }; - - const startOptions = []; - if (isInteractive) { - startOptions.push({ label: document.getElementById('branch-a-label')?.value || 'A', nextNodeName: 'BRANCH_A' }); - startOptions.push({ label: document.getElementById('branch-b-label')?.value || 'B', nextNodeName: 'BRANCH_B' }); - } - - const nodes = [ - { - nodeName: 'START', - isStartNode: true, - autoNextNode: isInteractive ? null : 'CLOSING', - scripts: extractScripts('start-node-container'), - interactiveOptions: startOptions - } - ]; - - if (isInteractive) { - nodes.push({ nodeName: 'BRANCH_A', isStartNode: false, autoNextNode: 'CLOSING', scripts: extractScripts('branch-a-node-container'), interactiveOptions: [] }); - nodes.push({ nodeName: 'BRANCH_B', isStartNode: false, autoNextNode: 'CLOSING', scripts: extractScripts('branch-b-node-container'), interactiveOptions: [] }); - } - - nodes.push({ nodeName: 'CLOSING', isStartNode: false, autoNextNode: null, scripts: extractScripts('closing-node-container'), interactiveOptions: [] }); - - const voiceSettings = {}; - const voiceInputMap = { - NARRATOR: 'tts-voice-narrator', - A: 'tts-voice-a', - B: 'tts-voice-b', - USER: 'tts-voice-user' - }; - - Object.entries(voiceInputMap).forEach(([speakerType, inputId]) => { - const value = document.getElementById(inputId)?.value?.trim(); - if (value) voiceSettings[speakerType] = value; - }); - - if (action === 'PUBLISHED' || action === 'PUBLISH') { - const requiredSpeakers = new Set(); - nodes.forEach((node) => { - (node.scripts || []).forEach((script) => { - if (script.speakerType) requiredSpeakers.add(script.speakerType); - }); - }); - - const missingVoiceSpeakers = Array.from(requiredSpeakers).filter((speakerType) => !voiceSettings[speakerType]); - if (missingVoiceSpeakers.length > 0) { - throw new Error(`다음 화자의 Fish Audio reference_id가 없습니다: ${missingVoiceSpeakers.join(', ')}`); - } - } - - const scenarioPayload = { - battleId: savedId, - isInteractive, - nodes, - status: resolvedStatus, - voiceSettings - }; - - const scenarioExisted = !!PickeData.scenarioId; - const scenarioMethod = scenarioExisted ? 'PUT' : 'POST'; - const scenarioUrl = scenarioExisted ? `/api/v1/admin/scenarios/${PickeData.scenarioId}` : '/api/v1/admin/scenarios'; - - const scenarioRes = await fetch(scenarioUrl, { - method: scenarioMethod, - headers: PickeData.getAuthHeaders(), - body: JSON.stringify(scenarioPayload) - }); - if (!scenarioRes.ok) throw new Error('시나리오 저장에 실패했습니다.'); - - const scenarioData = await scenarioRes.json(); - if (!scenarioExisted) { - const scenarioResult = scenarioData.result || scenarioData.data || {}; - PickeData.scenarioId = scenarioResult.scenarioId || scenarioResult.id || null; - } - - if (scenarioExisted && PickeData.scenarioId) { - const shouldPatchScenarioStatus = - action === 'PUBLISHED' - || action === 'PUBLISH' - || previousStatus !== resolvedStatus; - if (shouldPatchScenarioStatus) { - const statusRes = await fetch(`/api/v1/admin/scenarios/${PickeData.scenarioId}`, { - method: 'PATCH', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ status: resolvedStatus }) - }); - if (!statusRes.ok) throw new Error('시나리오 상태 업데이트에 실패했습니다.'); - } - } - } - - PickeData.currentStatus = resolvedStatus; - - if (loader) { - loader.classList.add('hidden'); - loader.classList.remove('flex'); - } - - const modal = document.getElementById('custom-modal'); - if (!modal) { - window.location.href = '/api/v1/admin/picke/list'; - return; - } - - document.getElementById('custom-modal-title').innerText = '완료'; - document.getElementById('custom-modal-message').innerText = action === 'PENDING' - ? '임시 저장되었습니다.' - : action === 'SCHEDULED' - ? '예약 저장되었습니다.' - : action === 'EDIT' - ? '수정이 완료되었습니다.' - : '발행이 완료되었습니다.'; - - modal.classList.remove('hidden'); - setTimeout(() => { - modal.classList.add('opacity-100'); - modal.classList.remove('opacity-0'); - }, 10); - - document.getElementById('custom-modal-confirm').onclick = () => { - window.location.href = '/api/v1/admin/picke/list'; - }; - } catch (e) { - if (loader) { - loader.classList.add('hidden'); - loader.classList.remove('flex'); - } - console.error('저장 중 오류:', e); - alert(`저장에 실패했습니다: ${e.message}`); - } -}; diff --git a/src/main/resources/static/js/admin/chat/chat-audio.js b/src/main/resources/static/js/admin/chat/chat-audio.js deleted file mode 100644 index 6112fdc4..00000000 --- a/src/main/resources/static/js/admin/chat/chat-audio.js +++ /dev/null @@ -1,48 +0,0 @@ -function _getAudio() { return document.getElementById('preview-audio'); } - -window.toggleAudio = function () { - const a = _getAudio(); - if (!a) return; - if (!a.src || a.src === window.location.href) { _openAudioPicker(); return; } - a.paused ? a.play() : a.pause(); -}; - -function _updatePlayIcon() { - const a = _getAudio(); - const icon = document.getElementById('audio-play-icon'); - if (!icon) return; - icon.setAttribute('d', (a && !a.paused) ? 'M6 19h4V5H6v14zm8-14v14h4V5h-4z' : 'M8 5v14l11-7z'); -} - -// 파일 선택 팝업 -function _openAudioPicker() { - let picker = document.getElementById('audio-file-picker') || document.createElement('input'); - picker.type = 'file'; picker.accept = 'audio/*'; picker.id = 'audio-file-picker'; - picker.style.display = 'none'; - if (!document.getElementById('audio-file-picker')) document.body.appendChild(picker); - - picker.onchange = (e) => { - const file = e.target.files[0]; - if (file) { - const a = _getAudio(); - a.src = URL.createObjectURL(file); - a.play(); - } - }; - picker.click(); -} - -// 이벤트 리스너 등록 -document.addEventListener('DOMContentLoaded', () => { - const a = _getAudio(); - if (!a) return; - - a.addEventListener('timeupdate', () => { - const pct = a.duration ? (a.currentTime / a.duration * 100) : 0; - if (document.getElementById('audio-progress-fill')) document.getElementById('audio-progress-fill').style.width = pct + '%'; - if (document.getElementById('audio-current-time')) document.getElementById('audio-current-time').textContent = Math.floor(a.currentTime / 60) + ":" + Math.floor(a.currentTime % 60).toString().padStart(2, '0'); - }); - - a.addEventListener('play', _updatePlayIcon); - a.addEventListener('pause', _updatePlayIcon); -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/chat/chat-editor.js b/src/main/resources/static/js/admin/chat/chat-editor.js deleted file mode 100644 index 1cb56fdb..00000000 --- a/src/main/resources/static/js/admin/chat/chat-editor.js +++ /dev/null @@ -1,404 +0,0 @@ -// 대본 입력창 자동 높이 -const resizeScriptTextarea = (textarea) => { - if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.overflowY = 'hidden'; - textarea.style.height = `${textarea.scrollHeight}px`; -}; - -const bindScriptTextareaAutosize = (textarea) => { - if (!textarea) return; - textarea.addEventListener('input', () => { - resizeScriptTextarea(textarea); - if (window.updateChatPreview) window.updateChatPreview(); - }); - resizeScriptTextarea(textarea); -}; - -const initScriptTextareaAutosize = () => { - document.querySelectorAll('.script-text').forEach(bindScriptTextareaAutosize); -}; - -window.addScriptBlock = (containerId, speaker) => { - const block = document.createElement('div'); - block.className = 'flex items-start gap-4 script-block bg-white border border-gray-100 p-4 rounded-2xl shadow-sm mb-3 group'; - block.innerHTML = ` - -
- - -
- - `; - - document.getElementById(containerId)?.appendChild(block); - - const ta = block.querySelector('textarea'); - bindScriptTextareaAutosize(ta); -}; - -// 감정 태그 삽입 -document.addEventListener('change', (e) => { - if (!e.target.classList.contains('emotion-insert-btn')) return; - const textarea = e.target.closest('.script-block')?.querySelector('.script-text'); - if (e.target.value && textarea) { - const tag = `[${e.target.value}]`; - const start = textarea.selectionStart; - textarea.value = textarea.value.substring(0, start) + tag + textarea.value.substring(textarea.selectionEnd); - e.target.value = ''; - if (window.updateChatPreview) window.updateChatPreview(); - textarea.focus(); - textarea.selectionEnd = start + tag.length; - } -}); - -// 분기 열기/닫기 -window.addBranchBlock = () => { - document.getElementById('branch-container')?.classList.remove('hidden'); - document.getElementById('btn-add-branch')?.classList.add('hidden'); -}; - -window.removeBranchBlock = () => { - document.getElementById('branch-container')?.classList.add('hidden'); - document.getElementById('btn-add-branch')?.classList.remove('hidden'); - if (window.updateChatPreview) window.updateChatPreview(); -}; - -// 채팅 미리보기 업데이트 -window.updateChatPreview = function () { - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - chatContainer.innerHTML = ''; - - const nameA = document.getElementById('char-a-rep')?.value || '화자 A'; - const nameB = document.getElementById('char-b-rep')?.value || '화자 B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 섹션별 대본 렌더링 - const renderBlocks = (containerSelector, sectionTitle = null) => { - const blocks = document.querySelectorAll(`${containerSelector} .script-block`); - if (blocks.length > 0 && sectionTitle) { - chatContainer.innerHTML += `
${sectionTitle}
`; - } - - blocks.forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - - // [태그]와 html 태그 제거 - let text = ta.value.replace(/\[.*?\]/g, '').replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - - if (type === 'NARRATOR') { - chatContainer.innerHTML += ` -
-
-

${text}

-
-
`; - } else if (type === 'A') { - chatContainer.innerHTML += ` -
-
-
-

${nameA}

-
- ${text} -
-
-
`; - } else if (type === 'B') { - chatContainer.innerHTML += ` -
-
-
-

${nameB}

-
- ${text} -
-
-
`; - } - }); - }; - - // 1. 시작 대본 - renderBlocks('#start-node-container'); - - // 2. 분기 버튼 업데이트 - const branchContainer = document.getElementById('branch-container'); - const branchChoiceUI = document.getElementById('preview-branch-choice'); - - if (branchContainer && !branchContainer.classList.contains('hidden')) { - if (branchChoiceUI) branchChoiceUI.classList.remove('hidden'); - - const labelA = document.getElementById('branch-a-label')?.value || 'A 선택지를 입력하세요'; - const labelB = document.getElementById('branch-b-label')?.value || 'B 선택지를 입력하세요'; - - const btnA = document.getElementById('branch-btn-a'); - const btnB = document.getElementById('branch-btn-b'); - - if (btnA) btnA.innerHTML = labelA.replace(/\n/g, '
'); - if (btnB) btnB.innerHTML = labelB.replace(/\n/g, '
'); - - renderBlocks('#branch-a-node-container', 'OPTION A PATH'); - renderBlocks('#branch-b-node-container', 'OPTION B PATH'); - } else { - if (branchChoiceUI) branchChoiceUI.classList.add('hidden'); - } - - // 3. 클로징 대본 - renderBlocks('#closing-node-container'); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 분기 버튼 텍스트 입력 시 즉시 미리보기 반영 -document.addEventListener('input', (e) => { - if (e.target.id === 'branch-a-label' || e.target.id === 'branch-b-label') { - if (window.updateChatPreview) window.updateChatPreview(); - } -}); - -// 분기 선택 (사용자 미리보기용) -window.selectBranch = function (branch) { - const branchChoice = document.getElementById('preview-branch-choice'); - if (branchChoice) branchChoice.classList.add('hidden'); - - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - - const nameA = document.getElementById('char-a-rep')?.value || '화자 A'; - const nameB = document.getElementById('char-b-rep')?.value || '화자 B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 선택 배너 - const choiceLabel = branch === 'A' - ? (document.getElementById('branch-a-label')?.value || 'A 선택') - : (document.getElementById('branch-b-label')?.value || 'B 선택'); - chatContainer.innerHTML += `
"${choiceLabel}" 선택
`; - - // 선택된 분기 대본 - const nodeId = branch === 'A' ? 'branch-a-node-container' : 'branch-b-node-container'; - document.querySelectorAll(`#${nodeId} .script-block`).forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - let text = ta.value.replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - - // 클로징 대본 - document.querySelectorAll('#closing-node-container .script-block').forEach(block => { - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - let text = ta.value.replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 말풍선 HTML 생성 -function _buildBubble(type, text, nameA, nameB, charAImg, charBImg) { - if (type === 'NARRATOR') { - return `

${text}

`; - } - if (type === 'A') { - return ` -
-
-
-

${nameA}

-
${text}
-
-
`; - } - if (type === 'B') { - return ` -
-
-
-

${nameB}

-
${text}
-
-
`; - } - return ''; -} - -// 오디오 플레이어 -function _getAudio() { - return document.getElementById('preview-audio'); -} - -function _formatTime(secs) { - if (isNaN(secs) || !isFinite(secs)) return '0:00'; - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -function _updatePlayIcon() { - const a = _getAudio(); - const icon = document.getElementById('audio-play-icon'); - if (!icon) return; - icon.setAttribute('d', (a && !a.paused) ? 'M6 19h4V5H6v14zm8-14v14h4V5h-4z' : 'M8 5v14l11-7z'); -} - -window.toggleAudio = function () { - const a = _getAudio(); - if (!a) return; - if (!a.src || a.src === window.location.href) { - _openAudioPicker(); - return; - } - a.paused ? a.play() : a.pause(); -}; - -window.seekRelative = function (seconds) { - const a = _getAudio(); - if (!a || !a.duration) return; - a.currentTime = Math.max(0, Math.min(a.duration, a.currentTime + seconds)); -}; - -window.seekAudio = function (event) { - const a = _getAudio(); - const bar = document.getElementById('audio-progress-bar'); - if (!a || !a.duration || !bar) return; - const rect = bar.getBoundingClientRect(); - a.currentTime = ((event.clientX - rect.left) / rect.width) * a.duration; -}; - -function _openAudioPicker() { - let picker = document.getElementById('audio-file-picker'); - if (!picker) { - picker = document.createElement('input'); - picker.type = 'file'; - picker.accept = 'audio/*'; - picker.id = 'audio-file-picker'; - picker.style.display = 'none'; - document.body.appendChild(picker); - picker.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - const a = _getAudio(); - if (!a) return; - a.src = URL.createObjectURL(file); - a.load(); - a.play(); - }); - } - picker.click(); -} - -document.addEventListener('DOMContentLoaded', () => { - initScriptTextareaAutosize(); - - const a = _getAudio(); - if (!a) return; - - a.addEventListener('timeupdate', () => { - const pct = a.duration ? (a.currentTime / a.duration * 100) : 0; - const fill = document.getElementById('audio-progress-fill'); - const thumb = document.getElementById('audio-progress-thumb'); - const cur = document.getElementById('audio-current-time'); - if (fill) fill.style.width = pct + '%'; - if (thumb) thumb.style.left = pct + '%'; - if (cur) cur.textContent = _formatTime(a.currentTime); - }); - - a.addEventListener('durationchange', () => { - const total = document.getElementById('audio-total-time'); - if (total) total.textContent = _formatTime(a.duration); - }); - - a.addEventListener('play', _updatePlayIcon); - a.addEventListener('pause', _updatePlayIcon); - a.addEventListener('ended', _updatePlayIcon); - - // 드래그 앤 드롭으로 오디오 파일 로드 - const playerArea = document.querySelector('#preview-battle-chat .h-\\[100px\\]'); - if (playerArea) { - playerArea.addEventListener('dragover', e => e.preventDefault()); - playerArea.addEventListener('drop', e => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (!file || !file.type.startsWith('audio/')) return; - a.src = URL.createObjectURL(file); - a.load(); - a.play(); - }); - } -}); - -// 미리보기 화면 전환 -window.switchToChatView = function () { - document.getElementById('preview-battle-intro')?.classList.add('hidden'); - document.getElementById('preview-battle-chat')?.classList.remove('hidden'); - if (window.updateChatPreview) window.updateChatPreview(); -}; - -window.switchToIntroView = function () { - document.getElementById('preview-battle-chat')?.classList.add('hidden'); - document.getElementById('preview-battle-intro')?.classList.remove('hidden'); - const a = _getAudio(); - if (a) { - a.pause(); - a.currentTime = 0; - _updatePlayIcon(); - } -}; - -window.resetChatPreview = function () { - if (window.updateChatPreview) window.updateChatPreview(); - const a = _getAudio(); - if (a) { - a.pause(); - a.currentTime = 0; - _updatePlayIcon(); - } -}; diff --git a/src/main/resources/static/js/admin/chat/chat-preview.js b/src/main/resources/static/js/admin/chat/chat-preview.js deleted file mode 100644 index f8ff26f9..00000000 --- a/src/main/resources/static/js/admin/chat/chat-preview.js +++ /dev/null @@ -1,119 +0,0 @@ -window.updateChatPreview = function () { - const chatContainer = document.getElementById('preview-chat-container'); - if (!chatContainer) return; - chatContainer.innerHTML = ''; - - const nameA = document.getElementById('char-a-rep')?.value || 'A'; - const nameB = document.getElementById('char-b-rep')?.value || 'B'; - const charAImg = document.getElementById('char-a-img-bg')?.style.backgroundImage || ''; - const charBImg = document.getElementById('char-b-img-bg')?.style.backgroundImage || ''; - - // 섹션별 대사 렌더링 헬퍼 - const renderBlocks = (containerSelector, sectionTitle = null) => { - const blocks = document.querySelectorAll(`${containerSelector} .script-block`); - - if (blocks.length > 0 && sectionTitle) { - chatContainer.innerHTML += `
${sectionTitle}
`; - } - - blocks.forEach(block => { - // 화자 타입 가져오기 (A, B, NARRATOR) - const type = block.querySelector('.speaker-select')?.value || block.dataset.speaker; - const ta = block.querySelector('.script-text'); - if (!ta) return; - - // [태그] 숨김 처리 및 줄바꿈 변환 - let text = ta.value.replace(/\[.*?\]/g, '').replace(/<[^>]+>/g, '').trim(); - if (!text) return; - text = text.replace(/\n/g, '
'); - - // 화자 타입에 따라 말풍선 생성 - chatContainer.innerHTML += _buildBubble(type, text, nameA, nameB, charAImg, charBImg); - }); - }; - - // 1. 시작 노드 (오프닝) - renderBlocks('#start-node-container'); - - // 2. 분기점 처리 - const branchContainer = document.getElementById('branch-container'); - const branchChoiceUI = document.getElementById('preview-branch-choice'); - - if (branchContainer && !branchContainer.classList.contains('hidden')) { - if (branchChoiceUI) branchChoiceUI.classList.remove('hidden'); - - const labelA = document.getElementById('branch-a-label')?.value || 'A 선택지'; - const labelB = document.getElementById('branch-b-label')?.value || 'B 선택지'; - - if (document.getElementById('branch-btn-a')) document.getElementById('branch-btn-a').innerHTML = labelA.replace(/\n/g, '
'); - if (document.getElementById('branch-btn-b')) document.getElementById('branch-btn-b').innerHTML = labelB.replace(/\n/g, '
'); - - renderBlocks('#branch-a-node-container', 'OPTION A PATH'); - renderBlocks('#branch-b-node-container', 'OPTION B PATH'); - } else { - if (branchChoiceUI) branchChoiceUI.classList.add('hidden'); - } - - // 3. 클로징 노드 - renderBlocks('#closing-node-container'); - - chatContainer.scrollTop = chatContainer.scrollHeight; -}; - -// 말풍선 HTML 생성 헬퍼 -function _buildBubble(type, text, nameA, nameB, charAImg, charBImg) { - if (type === 'NARRATOR') { - return ` -
-
-

${text}

-
-
`; - } - - const isA = (type === 'A'); - const name = isA ? nameA : nameB; - const img = isA ? charAImg : charBImg; - const alignClass = isA ? '' : 'flex-row-reverse'; - const textAlign = isA ? '' : 'items-end'; - const namePadding = isA ? 'pl-1' : 'pr-1'; - const bubbleClass = isA - ? 'bg-white border-[#EBEBEB] text-gray-800 rounded-tl-none' - : 'bg-[#FDFBF9] border-[#EBE2D5] text-[#5A4A35] rounded-tr-none'; - - return ` -
-
-
-

${name}

-
- ${text} -
-
-
`; -} - -// 화면 전환 핸들러 -window.switchToChatView = function () { - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - if (intro && chat) { - intro.classList.add('hidden'); - chat.classList.remove('hidden'); - window.updateChatPreview(); - } -}; - -window.switchToIntroView = function () { - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - if (intro && chat) { - chat.classList.add('hidden'); - intro.classList.remove('hidden'); - } -}; - -window.resetChatPreview = function () { - window.updateChatPreview(); -}; \ No newline at end of file diff --git a/src/main/resources/static/js/admin/core.js b/src/main/resources/static/js/admin/core.js deleted file mode 100644 index 8b04a42e..00000000 --- a/src/main/resources/static/js/admin/core.js +++ /dev/null @@ -1,71 +0,0 @@ -const token = localStorage.getItem("adminToken"); -if (!token) { - alert("로그인이 필요합니다."); - window.location.replace("/api/v1/admin/login"); -} - -const urlParams = new URLSearchParams(window.location.search); - -window.PickeData = { - currentContentId: urlParams.get('id'), - currentTypeParam: (() => { - const type = (urlParams.get('type') || 'BATTLE').toUpperCase(); - return type === 'VOTE' ? 'POLL' : type; - })(), - isEditMode: !!urlParams.get('id'), - token, - currentContentType: 'BATTLE', - currentTargetDate: null, - currentStatus: null, - allTags: [], - selections: { - CATEGORY: [], - BATTLE_A_PHILOSOPHER: [], - BATTLE_A_VALUE: [], - BATTLE_B_PHILOSOPHER: [], - BATTLE_B_VALUE: [] - }, - currentTagTarget: 'CATEGORY', - tempSelections: [], - uploadedFiles: { thumbnail: null, charA: null, charB: null }, - editingTagId: null, - - API: { - TAGS: '/api/v1/tags', - TAG_CREATE: '/api/v1/admin/tags', - TAG_UPDATE: (id) => `/api/v1/admin/tags/${id}`, - TAG_DELETE: (id) => `/api/v1/admin/tags/${id}`, - BATTLE_CREATE: '/api/v1/admin/battles', - BATTLE_UPDATE: (id) => `/api/v1/admin/battles/${id}`, - BATTLE_GET: (id) => `/api/v1/admin/battles/${id}`, - QUIZ_CREATE: '/api/v1/admin/quizzes', - QUIZ_UPDATE: (id) => `/api/v1/admin/quizzes/${id}`, - QUIZ_GET: (id) => `/api/v1/admin/quizzes/${id}`, - POLL_CREATE: '/api/v1/admin/polls', - POLL_UPDATE: (id) => `/api/v1/admin/polls/${id}`, - POLL_GET: (id) => `/api/v1/admin/polls/${id}`, - FILE_UPLOAD: '/api/v1/files/upload', - FILE_UPLOAD_LOCAL: '/api/v1/files/upload/local' - }, - - getAuthHeaders: () => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${window.PickeData.token}` - }), - - setValue: (id, value) => { - const el = document.getElementById(id); - if (el && value != null) el.value = value; - }, - - setPreviewImage: (bgId, placeholderId, targetImgId, url) => { - const bg = document.getElementById(bgId); - if (bg) { - bg.style.backgroundImage = `url(${url})`; - bg.style.opacity = '1'; - } - document.getElementById(placeholderId)?.classList.add('hidden'); - const target = document.getElementById(targetImgId); - if (target) target.style.backgroundImage = `url(${url})`; - } -}; diff --git a/src/main/resources/static/js/admin/notice/notice.js b/src/main/resources/static/js/admin/notice/notice.js deleted file mode 100644 index 821eb370..00000000 --- a/src/main/resources/static/js/admin/notice/notice.js +++ /dev/null @@ -1,161 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const token = localStorage.getItem("adminToken"); - if (!token) { - alert("로그인이 필요합니다."); - window.location.replace("/api/v1/admin/login"); - return; - } - - const api = { - list: (page = 0, size = 20, category = "ALL") => { - const params = new URLSearchParams({ page: String(page), size: String(size) }); - if (category && category !== "ALL") { - params.set("category", category); - } - return `/api/v1/admin/notices?${params.toString()}`; - }, - create: "/api/v1/admin/notices" - }; - - const categoryLabelMap = { - ALL: "전체", - CONTENT: "콘텐츠", - NOTICE: "공지사항", - EVENT: "이벤트" - }; - - const tbody = document.getElementById("notice-list-tbody"); - const form = document.getElementById("notice-form"); - const refreshButton = document.getElementById("refresh-notice-list"); - const createCategoryButtons = Array.from(document.querySelectorAll(".create-category-btn")); - const filterCategoryButtons = Array.from(document.querySelectorAll(".filter-category-btn")); - - let currentCreateCategory = "NOTICE"; - let currentFilterCategory = "ALL"; - - const authHeaders = () => ({ - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }); - - const renderDate = (dateTime) => { - if (!dateTime) return "-"; - return new Date(dateTime).toLocaleString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }); - }; - - const renderCategory = (category) => categoryLabelMap[category] || category; - - const applyButtonState = (buttons, activeValue, dataKey) => { - buttons.forEach((button) => { - const value = button.dataset[dataKey]; - const isActive = value === activeValue; - button.classList.toggle("border-black", isActive); - button.classList.toggle("bg-black", isActive); - button.classList.toggle("text-white", isActive); - button.classList.toggle("border-gray-200", !isActive); - button.classList.toggle("bg-gray-50", !isActive); - button.classList.toggle("text-gray-600", !isActive); - }); - }; - - const renderRows = (items) => { - tbody.innerHTML = ""; - if (!items || items.length === 0) { - tbody.innerHTML = `등록된 공지가 없습니다.`; - return; - } - - items.forEach((item) => { - const tr = document.createElement("tr"); - tr.className = "hover:bg-gray-50"; - tr.innerHTML = ` - ${item.notificationId} - - - ${renderCategory(item.category)} - - - ${item.title || "-"} - ${renderDate(item.createdAt)} - `; - tbody.appendChild(tr); - }); - }; - - const loadNotices = async () => { - tbody.innerHTML = `불러오는 중...`; - try { - const res = await fetch(api.list(0, 20, currentFilterCategory), { headers: authHeaders() }); - if (res.status === 401 || res.status === 403) { - alert("세션이 만료되었습니다. 다시 로그인해 주세요."); - window.location.replace("/api/v1/admin/login"); - return; - } - if (!res.ok) throw new Error(`공지 목록 조회 실패 (HTTP ${res.status})`); - const json = await res.json(); - const data = json.result || json.data || {}; - renderRows(data.items || []); - } catch (e) { - console.error("공지 목록 조회 오류:", e); - tbody.innerHTML = `공지 목록 로드에 실패했습니다.`; - } - }; - - createCategoryButtons.forEach((button) => { - button.addEventListener("click", () => { - currentCreateCategory = button.dataset.createCategory || "NOTICE"; - applyButtonState(createCategoryButtons, currentCreateCategory, "createCategory"); - }); - }); - - filterCategoryButtons.forEach((button) => { - button.addEventListener("click", async () => { - currentFilterCategory = button.dataset.filterCategory || "ALL"; - applyButtonState(filterCategoryButtons, currentFilterCategory, "filterCategory"); - await loadNotices(); - }); - }); - - form?.addEventListener("submit", async (event) => { - event.preventDefault(); - - const title = document.getElementById("notice-title")?.value?.trim() || ""; - const body = document.getElementById("notice-body")?.value?.trim() || ""; - - if (!title || !body) { - alert("제목과 내용을 입력해 주세요."); - return; - } - - const payload = { category: currentCreateCategory, title, body }; - - try { - const res = await fetch(api.create, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(payload) - }); - if (!res.ok) throw new Error(`공지 저장 실패 (HTTP ${res.status})`); - - document.getElementById("notice-title").value = ""; - document.getElementById("notice-body").value = ""; - alert("공지사항이 등록되었습니다."); - await loadNotices(); - } catch (e) { - console.error("공지 저장 오류:", e); - alert("공지 저장에 실패했습니다."); - } - }); - - refreshButton?.addEventListener("click", loadNotices); - - applyButtonState(createCategoryButtons, currentCreateCategory, "createCategory"); - applyButtonState(filterCategoryButtons, currentFilterCategory, "filterCategory"); - loadNotices(); -}); \ No newline at end of file diff --git a/src/main/resources/static/js/admin/tag/tags-controller.js b/src/main/resources/static/js/admin/tag/tags-controller.js deleted file mode 100644 index 2a6259c3..00000000 --- a/src/main/resources/static/js/admin/tag/tags-controller.js +++ /dev/null @@ -1,92 +0,0 @@ -window.fetchAllTags = async function () { - try { - const res = await fetch(PickeData.API.TAGS, { headers: PickeData.getAuthHeaders() }); - if (!res.ok) throw new Error(res.status); - - const json = await res.json(); - PickeData.allTags = json.result?.items ?? json.data?.items ?? []; - window.renderTagModalList(); - } catch (e) { - console.error('태그 목록 조회 실패:', e); - } -}; - -window.submitNewTag = async () => { - const nameInput = document.getElementById('tag-name-input'); - const typeSelect = document.getElementById('tag-type-select'); - if (!nameInput || !typeSelect) return; - - const name = nameInput.value.trim(); - const type = typeSelect.value; - - if (!name) { - alert('태그 이름을 입력해 주세요.'); - nameInput.focus(); - return; - } - - if (PickeData.editingTagId) { - try { - const res = await fetch(PickeData.API.TAG_UPDATE(PickeData.editingTagId), { - method: 'PATCH', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ name, type }) - }); - - if (res.ok) { - await window.fetchAllTags(); - window.refreshFormBadges(); - if (window.updatePreviewTags) window.updatePreviewTags(); - document.getElementById('tag-modal')?.classList.add('hidden'); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); - } - } catch (e) { - alert('태그 수정 중 오류가 발생했습니다.'); - } - return; - } - - try { - const res = await fetch(PickeData.API.TAG_CREATE, { - method: 'POST', - headers: PickeData.getAuthHeaders(), - body: JSON.stringify({ name, type }) - }); - - if (res.ok) { - await window.fetchAllTags(); - document.getElementById('tag-modal')?.classList.add('hidden'); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); - nameInput.value = ''; - } - } catch (e) { - alert('태그 생성 중 오류가 발생했습니다.'); - } -}; - -window.deleteTag = async function (tagId) { - if (!confirm('태그를 삭제하시겠습니까? 사용 중인 태그는 삭제되지 않을 수 있습니다.')) return; - - try { - const res = await fetch(PickeData.API.TAG_DELETE(tagId), { - method: 'DELETE', - headers: PickeData.getAuthHeaders() - }); - - if (res.ok) { - Object.keys(PickeData.selections).forEach((target) => { - if (PickeData.selections[target].includes(tagId)) { - window.removeTag(target, tagId); - } - }); - PickeData.tempSelections = PickeData.tempSelections.filter((id) => id !== tagId); - await window.fetchAllTags(); - return; - } - - const err = await res.json(); - alert(err.message || '삭제할 수 없는 태그입니다.'); - } catch (e) { - console.error('태그 삭제 실패:', e); - } -}; diff --git a/src/main/resources/static/js/admin/tag/tags-ui.js b/src/main/resources/static/js/admin/tag/tags-ui.js deleted file mode 100644 index 53be1b52..00000000 --- a/src/main/resources/static/js/admin/tag/tags-ui.js +++ /dev/null @@ -1,177 +0,0 @@ -window.TagTargetConfig = { - CATEGORY: { - containerIds: ['basic-tags-container'], - allowedTypes: ['CATEGORY'], - preview: true - }, - BATTLE_A_PHILOSOPHER: { - containerIds: ['battle-a-philosopher-tags-container'], - allowedTypes: ['PHILOSOPHER'] - }, - BATTLE_A_VALUE: { - containerIds: ['battle-a-value-tags-container'], - allowedTypes: ['VALUE'] - }, - BATTLE_B_PHILOSOPHER: { - containerIds: ['battle-b-philosopher-tags-container'], - allowedTypes: ['PHILOSOPHER'] - }, - BATTLE_B_VALUE: { - containerIds: ['battle-b-value-tags-container'], - allowedTypes: ['VALUE'] - } -}; - -window.openTagSelectModal = (target) => { - if (!PickeData.selections[target]) return; - PickeData.currentTagTarget = target; - PickeData.tempSelections = [...PickeData.selections[target]]; - const searchInput = document.getElementById('tag-search-input'); - if (searchInput) searchInput.value = ''; - window.renderTagModalList(); - document.getElementById('tag-select-modal')?.classList.remove('hidden'); -}; - -window.closeTagSelectModal = () => { - document.getElementById('tag-select-modal')?.classList.add('hidden'); -}; - -window.toggleTagSelection = (tagId) => { - PickeData.tempSelections = PickeData.tempSelections.includes(tagId) - ? PickeData.tempSelections.filter((id) => id !== tagId) - : [...PickeData.tempSelections, tagId]; - window.renderTagModalList(document.getElementById('tag-search-input')?.value || ''); -}; - -window.renderTagModalList = function (searchQuery = '') { - const container = document.getElementById('tag-list-container'); - if (!container) return; - - const currentConfig = window.TagTargetConfig[PickeData.currentTagTarget] || window.TagTargetConfig.CATEGORY; - const allowedTypes = currentConfig.allowedTypes || []; - - container.innerHTML = ''; - const filtered = PickeData.allTags.filter((tag) => { - const name = String(tag.name || ''); - const matchedName = name.includes(searchQuery); - const matchedType = allowedTypes.length === 0 || allowedTypes.includes(tag.type); - return matchedName && matchedType; - }); - - const groups = [ - { title: 'CATEGORY', type: 'CATEGORY' }, - { title: 'PHILOSOPHER', type: 'PHILOSOPHER' }, - { title: 'VALUE', type: 'VALUE' } - ]; - - groups - .filter((group) => allowedTypes.length === 0 || allowedTypes.includes(group.type)) - .forEach((group) => { - const tags = filtered.filter((tag) => tag.type === group.type); - if (!tags.length) return; - - let html = `

${group.title}

`; - tags.forEach((tag) => { - const tagId = tag.tagId || tag.id; - const selected = PickeData.tempSelections.includes(tagId); - html += `
- - -
`; - }); - container.innerHTML += `${html}
`; - }); -}; - -window.confirmTagSelection = () => { - const target = PickeData.currentTagTarget; - PickeData.selections[target] = [...PickeData.tempSelections]; - window.refreshFormBadges(target); - - if (window.TagTargetConfig[target]?.preview && window.updatePreviewTags) { - window.updatePreviewTags(); - } - - window.closeTagSelectModal(); -}; - -window.refreshFormBadges = (specificTarget = null) => { - const targets = specificTarget ? [specificTarget] : Object.keys(window.TagTargetConfig); - - targets.forEach((target) => { - const config = window.TagTargetConfig[target]; - if (!config) return; - - config.containerIds.forEach((containerId) => { - const container = document.getElementById(containerId); - if (!container) return; - - container.querySelectorAll('.tag-badge').forEach((el) => el.remove()); - - (PickeData.selections[target] || []).forEach((tagId) => { - const tag = PickeData.allTags.find((item) => (item.tagId || item.id) === tagId); - if (!tag) return; - - const badge = document.createElement('div'); - badge.className = 'tag-badge group relative inline-flex items-center px-3 py-1.5 bg-gray-100 text-gray-600 border border-gray-200 rounded-full text-[10px] font-bold mr-2 mb-2 transition-all hover:bg-gray-200'; - badge.innerHTML = `#${tag.name}×`; - container.insertBefore(badge, container.lastElementChild); - }); - }); - }); -}; - -window.removeTag = function (target, tagId) { - if (!PickeData.selections[target]) return; - - PickeData.selections[target] = PickeData.selections[target].filter((id) => id !== tagId); - window.refreshFormBadges(target); - - if (window.TagTargetConfig[target]?.preview && window.updatePreviewTags) { - window.updatePreviewTags(); - } -}; - -window.openTagCreateModal = () => { - PickeData.editingTagId = null; - document.querySelector('#tag-modal h2').innerText = '새 태그 생성'; - document.getElementById('tag-name-input').value = ''; - document.getElementById('tag-type-select').value = 'CATEGORY'; - document.getElementById('tag-select-modal')?.classList.add('hidden'); - document.getElementById('tag-modal')?.classList.remove('hidden'); -}; - -window.updateTagName = function (tagId) { - const tag = PickeData.allTags.find((item) => item.tagId === tagId || item.id === tagId); - if (!tag) return; - - PickeData.editingTagId = tagId; - document.querySelector('#tag-modal h2').innerText = '태그 수정'; - document.getElementById('tag-name-input').value = tag.name; - document.getElementById('tag-type-select').value = tag.type; - document.getElementById('tag-select-modal')?.classList.add('hidden'); - document.getElementById('tag-modal')?.classList.remove('hidden'); -}; - -window.updatePreviewTags = function () { - const box = document.getElementById('preview-tags'); - if (!box) return; - - box.innerHTML = (PickeData.selections.CATEGORY || []) - .map((tagId) => { - const tag = PickeData.allTags.find((item) => (item.tagId || item.id) === tagId); - return tag - ? `#${tag.name}` - : ''; - }) - .join(''); -}; - -document.addEventListener('DOMContentLoaded', () => { - document.getElementById('tag-search-input')?.addEventListener('input', (e) => { - window.renderTagModalList(e.target.value.trim()); - }); -}); diff --git a/src/main/resources/static/js/admin/ui/ui-interaction.js b/src/main/resources/static/js/admin/ui/ui-interaction.js deleted file mode 100644 index 9846ee9e..00000000 --- a/src/main/resources/static/js/admin/ui/ui-interaction.js +++ /dev/null @@ -1,150 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - - // 섹션 클릭 시 인트로 ↔ 채팅 화면 자동 전환 - document.addEventListener('focusin', (e) => { - // 배틀 타입일 때만 작동 - if (PickeData.currentContentType !== 'BATTLE') return; - - const intro = document.getElementById('preview-battle-intro'); - const chat = document.getElementById('preview-battle-chat'); - const statusBar = document.getElementById('status-bar'); - - if (!intro || !chat) return; - - // 1. 기본정보나 인물 정보 섹션을 클릭하면 -> 인트로 미리보기 - if (e.target.closest('#section-basic') || e.target.closest('#section-chars')) { - intro.classList.remove('hidden'); - chat.classList.add('hidden'); - // 상태바 글자색 흰색으로 (배경이 어두우므로) - if (statusBar) { - statusBar.classList.remove('text-black'); - statusBar.classList.add('text-white'); - } - } - // 2. 대본(시나리오) 섹션을 클릭하면 -> 채팅 미리보기 - else if (e.target.closest('#section-script')) { - intro.classList.add('hidden'); - chat.classList.remove('hidden'); - // 상태바 글자색 검정색으로 (배경이 밝으므로) - if (statusBar) { - statusBar.classList.remove('text-white'); - statusBar.classList.add('text-black'); - } - // 채팅 내용 최신화 - if (window.updateChatPreview) window.updateChatPreview(); - } - }); - - // 상단 타입 토글 (배틀 / 퀴즈 / 투표 탭 전환) - const toggleBtns = document.querySelectorAll('.type-toggle'); - toggleBtns.forEach(btn => { - btn.addEventListener('click', (e) => { - const target = e.currentTarget; - const targetId = target.dataset.target; - - toggleBtns.forEach(b => { - b.classList.remove('active', 'bg-white', 'text-black', 'shadow-sm'); - b.classList.add('text-gray-500'); - }); - target.classList.remove('text-gray-500'); - target.classList.add('active', 'bg-white', 'text-black', 'shadow-sm'); - - document.querySelectorAll('.content-form').forEach(form => form.classList.add('hidden')); - document.getElementById(targetId)?.classList.remove('hidden'); - - document.querySelectorAll('[id^="preview-wrapper-"]').forEach(pw => pw.classList.add('hidden')); - const typeKey = targetId.replace('form-', ''); - document.getElementById(`preview-wrapper-${typeKey}`)?.classList.remove('hidden'); - - const contentTypeByForm = { battle: 'BATTLE', quiz: 'QUIZ', vote: 'POLL' }; - PickeData.currentContentType = contentTypeByForm[typeKey] || typeKey.toUpperCase(); - - // Update status bar type indicator - const statusType = document.getElementById('status-type'); - if (statusType) { - statusType.textContent = PickeData.currentContentType; - } - - // Show BRANCH MODE only for battle - const branchMode = document.getElementById('branch-mode-indicator'); - if (branchMode) { - branchMode.classList.toggle('hidden', typeKey !== 'battle'); - } - }); - }); - - // 배틀 미리보기 카드 선택 인터랙션 - const cardA = document.getElementById('pv-battle-card-a'); - const cardB = document.getElementById('pv-battle-card-b'); - - const resetBattleSelection = () => { - [cardA, cardB].forEach(c => { - if (!c) return; - c.classList.replace('border-[#7C4A3A]', 'border-[#E5E0D8]'); - c.classList.remove('bg-orange-50'); - }); - } - - cardA?.addEventListener('click', () => { - resetBattleSelection(); - cardA.classList.replace('border-[#E5E0D8]', 'border-[#7C4A3A]'); - cardA.classList.add('bg-orange-50'); - }); - - cardB?.addEventListener('click', () => { - resetBattleSelection(); - cardB.classList.replace('border-[#E5E0D8]', 'border-[#7C4A3A]'); - cardB.classList.add('bg-orange-50'); - }); - - // 시계 업데이트 - const _updateClock = () => { - const el = document.getElementById('status-time'); - if (el) { - const now = new Date(); - el.textContent = `${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`; - } - }; - _updateClock(); - setInterval(_updateClock, 30000); - - // Initialize status type - const statusType = document.getElementById('status-type'); - if (statusType) { - statusType.textContent = PickeData.currentContentType || 'BATTLE'; - } - - // Initialize BRANCH MODE visibility (show for battle) - const branchMode = document.getElementById('branch-mode-indicator'); - if (branchMode) { - branchMode.classList.toggle('hidden', (PickeData.currentContentType || 'BATTLE') !== 'BATTLE'); - } - - // Dirty Flag: 스크립트 블록 수정 추적 (이벤트 위임) - document.getElementById('section-script')?.addEventListener('input', (e) => { - if (e.target.classList.contains('script-text')) { - const scriptBlock = e.target.closest('.script-block'); - if (scriptBlock) { - const modFlag = scriptBlock.querySelector('.mod-flag'); - if (modFlag) { - modFlag.value = 'true'; - } - // 시각적 피드백: 테두리 색상 변경 - scriptBlock.classList.add('border-blue-300'); - } - } - }); -}); - -// 전역 투표 옵션 선택 함수 -window.selectVoteOption = function (btn) { - document.querySelectorAll('.vote-option-btn').forEach(b => { - b.classList.remove('border-[#D4B886]', 'bg-[#FDFBF7]'); - b.classList.add('border-[#EBE2D5]', 'bg-[#FAFAFA]'); - }); - btn.classList.add('border-[#D4B886]', 'bg-[#FDFBF7]'); - const optId = btn.getAttribute('data-opt-id'); - const optText = document.getElementById(`pv-vote-opt${optId}`)?.textContent || ''; - const blank = document.getElementById('pv-vote-blank'); - if (blank) blank.textContent = optText || '?'; -}; \ No newline at end of file diff --git a/src/main/resources/static/js/admin/ui/ui-sync.js b/src/main/resources/static/js/admin/ui/ui-sync.js deleted file mode 100644 index f2bd2509..00000000 --- a/src/main/resources/static/js/admin/ui/ui-sync.js +++ /dev/null @@ -1,49 +0,0 @@ -document.addEventListener('input', (e) => { - const id = e.target.id; - const val = e.target.value; - const fv = val.replace(/\n/g, '
'); - - if (e.target.tagName.toLowerCase() === 'textarea') { - e.target.style.height = 'auto'; - e.target.style.height = `${e.target.scrollHeight}px`; - } - - const set = (elId, content, html = false) => { - const el = document.getElementById(elId); - if (!el) return; - if (html) el.innerHTML = content; - else el.innerText = content; - }; - - if (id === 'content-title') { - set('preview-title-intro', fv || '제목', true); - set('preview-title-chat', val || '설명'); - } - if (id === 'content-desc') set('preview-desc', fv || '설명', true); - - if (id === 'char-a-title') set('preview-char-a-title', val || '주장'); - if (id === 'char-a-rep') set('preview-char-a-rep', val || '철학자'); - if (id === 'char-a-stance') set('preview-char-a-stance', fv, true); - - if (id === 'char-b-title') set('preview-char-b-title', val || '주장'); - if (id === 'char-b-rep') set('preview-char-b-rep', val || '철학자'); - if (id === 'char-b-stance') set('preview-char-b-stance', fv, true); - - if (id === 'quiz-title') set('pv-quiz-q', fv || '퀴즈 제목', true); - if (id === 'quiz-option-a-title') set('pv-quiz-o-text', val || '참여문학'); - if (id === 'quiz-option-a-detail') set('pv-quiz-o-desc', val || '참여문학은 좋습니다.'); - if (id === 'quiz-option-b-title') set('pv-quiz-x-text', val || '순수문학'); - if (id === 'quiz-option-b-detail') set('pv-quiz-x-desc', val || '순수문학은 좋습니다.'); - - if (id === 'poll-title-prefix') set('pv-vote-prefix', val || '나에게 예술이란'); - if (id === 'poll-title-suffix') set('pv-vote-suffix', val || ' 하는 행위이다.'); - if (id.startsWith('poll-option-') && id.endsWith('-title')) { - const num = id.split('-')[2]; - set(`pv-vote-opt${num}`, val || `Option ${num}`); - set(`pv-bar-label-${num}`, val || `Option ${num}`); - } - - if (id === 'branch-a-label' || id === 'branch-b-label' || id === 'char-a-rep' || id === 'char-b-rep') { - if (window.updateChatPreview) window.updateChatPreview(); - } -}); \ No newline at end of file diff --git a/src/main/resources/static/privacy-policy.html b/src/main/resources/static/privacy-policy.html new file mode 100644 index 00000000..393228a6 --- /dev/null +++ b/src/main/resources/static/privacy-policy.html @@ -0,0 +1,77 @@ + + + + + +PicKé 개인정보 처리방침 + + + + +

PicKé 개인정보 처리방침

+ +

제1조 (개인정보의 처리 목적)

+

회사는 다음의 목적을 위해 개인정보를 처리하며, 목적 외 용도로는 이용하지 않습니다.

+
    +
  1. 서비스 제공: 이용자의 투표 및 선택 데이터 기반 가치관 성향 분석 리포트 생성, 콘텐츠 추천.
  2. +
  3. 회원 관리: 회원제 서비스 이용에 따른 본인 식별, 부정 이용 방지, 각종 고지 전달.
  4. +
  5. 서비스 개선: 접속 빈도 파악 및 서비스 이용 통계 분석을 통한 서비스 고도화.
  6. +
+ +

제2조 (처리하는 개인정보 항목)

+

회사는 서비스 제공을 위해 아래의 항목을 수집합니다.

+
    +
  1. 회원 가입 및 소셜 로그인: 이메일, 닉네임, 프로필 이미지.
  2. +
  3. 서비스 이용 과정 중 자동 수집: +
      +
    • 서비스 내 콘텐츠 클릭 및 선택 기록(배틀 참여, 투표 등)
    • +
    • 의사결정 고민 시간(Latency) 및 콘텐츠 재생 구간 체류 기록
    • +
    • 기기정보(OS, 모델명), 접속 로그, IP 주소, 쿠키.
    • +
    • 광고 식별자(ADID/IDFA): 맞춤형 광고 제공 및 광고 효과 분석 목적.
    • +
    +
  4. +
+ +

제3조 (개인정보의 처리 및 보유 기간)

+
    +
  1. 회원 정보: 회원 탈퇴 시까지 보유.
  2. +
  3. 서비스 이용 기록: 서비스 이용 종료 시까지 또는 관련 법령(통신비밀보호법 등)에 따른 보존 기간(3개월) 동안 보관.
  4. +
+ +

제4조 (개인정보처리의 위탁)

+

회사는 서비스 분석 및 인프라 운영을 위해 다음과 같이 개인정보 처리 업무를 위탁하고 있습니다.

+
    +
  1. 서버 운영: Google Cloud Platform (데이터 저장 및 관리)
  2. +
  3. 사용자 행동 분석: Mixpanel, Inc. (서비스 이용 통계 및 행동 데이터 분석)
  4. +
  5. 광고 플랫폼: Google AdMob (맞춤형 광고 노출 제어)
  6. +
+ +

제5조 (정보주체의 권리·의무 및 행사방법)

+

이용자는 언제든지 자신의 개인정보 열람, 정정, 삭제, 처리 정지를 요구할 수 있습니다. 서비스 내 설정 메뉴를 통해 직접 처리하거나 고객센터를 통해 요청하실 수 있습니다.

+ +

제6조 (개인정보 자동 수집 장치의 설치·운영 및 거부)

+
    +
  1. 회사는 이용자에게 최적화된 서비스를 제공하기 위해 '쿠키(Cookie)' 및 '광고 식별자'를 사용합니다.
  2. +
  3. 이용자는 브라우저 설정 또는 모바일 기기 설정을 통해 쿠키 허용 거부 및 광고 추적 제한을 설정할 수 있습니다. 단, 거부 시 일부 맞춤형 서비스 이용이 어려울 수 있습니다.
  4. +
+ +

제7조 (개인정보의 안전성 확보조치)

+

회사는 개인정보 보호를 위해 접근 권한 관리, 데이터 암호화, 보안 프로그램 설치 등 기술적/관리적 조치를 다하고 있습니다.

+ +

제8조 (개인정보 보호책임자)

+
    +
  • 성명: 천다올 (대표자)
  • +
  • 담당부서: 서비스 운영팀
  • +
  • 연락처: picke.project@gmail.com
  • +
+ +

부칙
본 개인정보 처리방침은 2026년 4월 5일부터 시행됩니다.

+ + + diff --git a/src/main/resources/static/terms.html b/src/main/resources/static/terms.html new file mode 100644 index 00000000..494bcdab --- /dev/null +++ b/src/main/resources/static/terms.html @@ -0,0 +1,74 @@ + + + + + +PICKé 서비스 이용약관 + + + + +

PICKé 서비스 이용약관

+ +

제1조 (목적)

+

본 약관은 PICKé(이하 "회사")가 제공하는 지식 콘텐츠 큐레이션 및 가치관 분석 서비스(이하 "서비스")의 이용과 관련하여 회사와 이용자 간의 권리, 의무 및 책임사항, 서비스 이용조건 및 절차 등을 규정함을 목적으로 합니다.

+ +

제2조 (정의)

+
    +
  1. "서비스"란 회사가 운영하는 애플리케이션 및 웹을 통해 제공되는 지식 콘텐츠 시청, 투표 참여, 가치관 분석 리포트 등을 의미합니다.
  2. +
  3. "콘텐츠"란 회사가 생성한 텍스트, 오디오(TTS 포함), 이미지, 인터랙티브 분기형 대본 등을 말합니다.
  4. +
  5. "내공(포인트)"이란 서비스 내 활동에 따라 부여되는 무상의 가상 데이터로, 등급 상승 및 특정 기능 이용에 사용됩니다. 이는 현금으로 환급되지 않으며 제3자에게 유료로 양도할 수 없습니다.
  6. +
+ +

제3조 (서비스의 특징 및 기술적 제한)

+
    +
  1. 회사는 AI 및 TTS(Text-to-Speech) 기술을 활용하여 콘텐츠를 제공하며, 기술적 특성상 일시적인 음성-텍스트 싱크 불일치나 기계적 오류가 발생할 수 있습니다.
  2. +
  3. 회사가 제공하는 성향 분석 결과는 이용자의 유희와 자아 성찰을 돕기 위한 참고 자료일 뿐, 의학적·법률적·윤리적 판단의 절대적 기준이 될 수 없습니다.
  4. +
+ +

제4조 (회원가입 및 계정관리)

+
    +
  1. 이용자는 소셜 로그인 등을 통해 약관 동의 후 서비스를 이용할 수 있습니다.
  2. +
  3. 회원은 자신의 계정 및 식별코드 관리에 대한 책임을 지며, 타인의 정보 도용 시 서비스 이용이 영구 제한될 수 있습니다.
  4. +
+ +

제5조 (서비스 이용 및 제한)

+
    +
  1. 회사는 다음의 경우 이용을 제한하거나 게시물을 삭제할 수 있습니다. +
      +
    • 자동화된 수단(매크로, 봇 등)을 이용하여 부정한 방법으로 투표하거나 내공을 쌓는 행위
    • +
    • 서비스 내에서 타인에게 불쾌감을 주는 욕설, 비하, 특정 사상 강요 등의 행위
    • +
    +
  2. +
  3. 일일 투표 참여 한도를 초과한 경우, 광고 시청 등을 통해 추가 참여권을 획득해야 합니다.
  4. +
+ +

제6조 (저작권 및 데이터 활용)

+
    +
  1. 서비스 내 모든 콘텐츠와 성향 분석 리포트의 디자인/알고리즘에 대한 저작권은 회사에 귀속됩니다.
  2. +
  3. 회원은 자신의 분석 결과를 비영리적인 목적으로 소셜 미디어 등에 자유롭게 공유할 수 있습니다.
  4. +
  5. 회사는 서비스 고도화를 위해 이용자의 투표 데이터를 익명화하여 통계 및 분석 자료로 활용할 수 있습니다.
  6. +
+ +

제7조 (책임제한)

+
    +
  1. 회사는 천재지변, 기간통신사업자의 장애 등 불가항력으로 인한 서비스 중단에 책임을 지지 않습니다.
  2. +
  3. 회사는 콘텐츠가 담고 있는 철학적 견해의 객관적 타당성을 보장하지 않으며, 이용자의 선택에 따른 심리적 변화나 결과에 대해 책임을 지지 않습니다.
  4. +
+ +

제8조 (준거법 및 관할법원)

+
    +
  1. 본 약관의 해석 및 회사와 이용자 간의 분쟁에는 대한민국 법률을 적용합니다.
  2. +
  3. 서비스 이용과 관련하여 발생한 분쟁은 회사의 본사 소재지 관할 법원을 제1심 합의관할 법원으로 합니다.
  4. +
+ +

부칙
본 약관은 2026년 4월 5일부터 시행됩니다.

+ + + diff --git a/src/main/resources/templates/ad/landing.html b/src/main/resources/templates/ad/landing.html new file mode 100644 index 00000000..0bce255c --- /dev/null +++ b/src/main/resources/templates/ad/landing.html @@ -0,0 +1,84 @@ + + + + + + PICKE 추천 - 오늘의 제휴 상품 + + + + +
+
+

PICKE 추천

+

PICKE가 고른 오늘의 추천 상품과 제휴 혜택입니다.

+
+ +
+

추천 상품

+ + + +

준비 중인 추천 상품이 곧 올라옵니다.

+
+ +
+

+ 이 사이트는 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다. +

+

+ 개인정보처리방침 · + 이용약관 +

+

© PICKE

+
+
+ + diff --git a/src/main/resources/templates/admin/admin-login.html b/src/main/resources/templates/admin/admin-login.html deleted file mode 100644 index 60994273..00000000 --- a/src/main/resources/templates/admin/admin-login.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - Pické Admin - 로그인 - - - - - - - - - -
-
-

당신의 생각을

-

Pické

-
- - -
- - - - \ No newline at end of file diff --git a/src/main/resources/templates/admin/admin-notice.html b/src/main/resources/templates/admin/admin-notice.html deleted file mode 100644 index 3399da0a..00000000 --- a/src/main/resources/templates/admin/admin-notice.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Picke Admin - 공지사항 - - - - - - -
- -
-
-

공지사항 작성

-

저장하면 사용자 알림으로 노출됩니다.

- -
-
- -
- - - -
-
- -
- - -
- -
- - -
- - -
-
- -
-
-

최근 공지

- -
- -
-
- - - - -
-
- -
- - - - - - - - - - - - -
ID카테고리제목작성일
불러오는 중...
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/resources/templates/admin/components/form-battle.html b/src/main/resources/templates/admin/components/form-battle.html deleted file mode 100644 index e69cc775..00000000 --- a/src/main/resources/templates/admin/components/form-battle.html +++ /dev/null @@ -1,192 +0,0 @@ -
- -
-
-

1 기본 정보

- BATTLE -
- -
-
- - -
- -
- -
- -
-
- -
- - -
- -
- - -
- -
- - - -
- -
- - -
- -
- - -
-
-
- -
-
-

2 배틀 선택지

- OPTIONS -
- -
- - -
-

선택지 A

- - - - - - - -
- -
- -
- -
-
- -
-

선택지 B

- - - - - - - - -
- -
- -
- -
-
-
-
- -
-
-

3 시나리오 대본

- SCRIPT -
- -
-

TTS 목소리 설정

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
- -
-

START

- -
- -
-
- -
- - -
- - - -
-
- -
-

CLOSING

- -
- -
-
-
-
diff --git a/src/main/resources/templates/admin/components/form-quiz.html b/src/main/resources/templates/admin/components/form-quiz.html deleted file mode 100644 index 26b299de..00000000 --- a/src/main/resources/templates/admin/components/form-quiz.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
-
-

1 퀴즈 등록

- QUIZ -
- -
-
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- -
- - -
-
- - -
-
- - -
-
- -
- -
- - -
-
- - -
-
- - -
-
-
-
-
-
-
- diff --git a/src/main/resources/templates/admin/components/form-vote.html b/src/main/resources/templates/admin/components/form-vote.html deleted file mode 100644 index 26d3bbe2..00000000 --- a/src/main/resources/templates/admin/components/form-vote.html +++ /dev/null @@ -1,62 +0,0 @@ -
-
-
-

1 투표 등록

- POLL -
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
-
-
-
-
-
- diff --git a/src/main/resources/templates/admin/fragments/basic-info.html b/src/main/resources/templates/admin/fragments/basic-info.html deleted file mode 100644 index 88e2691d..00000000 --- a/src/main/resources/templates/admin/fragments/basic-info.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
-

1 기본정보

- BASIC INFO -
-
-
- - -
-
-
\ No newline at end of file diff --git a/src/main/resources/templates/admin/fragments/header.html b/src/main/resources/templates/admin/fragments/header.html deleted file mode 100644 index 1a59f4c5..00000000 --- a/src/main/resources/templates/admin/fragments/header.html +++ /dev/null @@ -1,16 +0,0 @@ -
-
-
- Picke - Admin -
- -
- -
- ADMIN -
-
diff --git a/src/main/resources/templates/admin/fragments/preview.html b/src/main/resources/templates/admin/fragments/preview.html deleted file mode 100644 index 58c99276..00000000 --- a/src/main/resources/templates/admin/fragments/preview.html +++ /dev/null @@ -1,202 +0,0 @@ -
- -
- 실시간 미리보기 - -
- -
-
- -
- 9:41 -
- - - -
-
- -
- -
-
-
- -
-
- - -
- -
-
-

제목을 입력해주세요

-

콘텐츠에 대한 배경 설명 또는 힌트가 이곳에 표시됩니다.

- -
-
-
-

주장

-

철학자

-
- -
VS
- -
-
-

주장

-

철학자

-
-
- - -
-
-
- - -
- - - - - -
-
-
\ No newline at end of file diff --git a/src/main/resources/templates/admin/picke-create.html b/src/main/resources/templates/admin/picke-create.html deleted file mode 100644 index 1795ff9a..00000000 --- a/src/main/resources/templates/admin/picke-create.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - Pické Admin - 콘텐츠 등록 - - - - - - - -
- -
- -
-
- -
- - - -
- -
-
-
- - - - - -
-
- -
-
-
- -
- -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/resources/templates/admin/picke-list.html b/src/main/resources/templates/admin/picke-list.html deleted file mode 100644 index 5da6ee50..00000000 --- a/src/main/resources/templates/admin/picke-list.html +++ /dev/null @@ -1,419 +0,0 @@ - - - - - - Picke Admin - 콘텐츠 관리 - - - - - - - -
- -
-
-
-

콘텐츠 관리

-

배틀, 퀴즈, 투표 콘텐츠를 확인하고 수정할 수 있습니다.

-
- -
- -
- - - - -
- -
- - - - - -
- -
- - - - - - - - - - - - - - - - - -
ID유형제목상태예약 발행생성일관리
-
-
- 데이터를 불러오는 중입니다... -
-
-
- -
-
- - - - diff --git a/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java b/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java new file mode 100644 index 00000000..f0d0335c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/client/AdpickCampaignResponseTest.java @@ -0,0 +1,94 @@ +package com.swyp.picke.domain.ad.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import java.io.InputStream; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 애드픽 offers.php 실제 응답으로 역직렬화를 확인한다. + * 픽스처는 실제 호출 결과를 그대로 옮긴 것이고, 잔여 0건과 아이콘 누락 건만 필터 경로 확인용으로 파생시켰다. + */ +class AdpickCampaignResponseTest { + + private static List campaigns; + + @BeforeAll + static void parseFixture() throws Exception { + try (InputStream in = AdpickCampaignResponseTest.class + .getResourceAsStream("/fixtures/ad/adpick-offers.json")) { + campaigns = new ObjectMapper().readValue(in, new TypeReference<>() { + }); + } + } + + @Test + @DisplayName("실제 응답의 필드가 우리가 쓰는 이름으로 모두 들어온다") + void deserializesFieldsWeUse() { + AdpickCampaignResponse first = campaigns.get(0); + + assertThat(first.offerId()).isEqualTo("9d3a9"); + assertThat(first.appTitle()).isNotBlank(); + assertThat(first.headline()).isNotBlank(); + assertThat(first.trackingLink()).startsWith("https://"); + assertThat(first.os()).isEqualTo("Both"); + assertThat(first.remain()).isPositive(); + assertThat(first.iconUrl()).startsWith("https://"); + } + + @Test + @DisplayName("문서에 없는 필드가 섞여 들어와도 파싱이 깨지지 않는다") + void ignoresUndocumentedFields() { + // 실제 응답에는 apKPI·apHook·apEvent 등 우리가 쓰지 않는 필드가 함께 온다. + assertThat(campaigns).hasSize(6); + } + + /** + * 실제 응답에 apOS 가 null 인 캠페인이 섞여 온다. OS 를 가리지 않는 것으로 보고 전체에 노출한다. + * iOS 전용 캠페인은 아직 실제 응답에서 관측하지 못해, iOS 매핑은 AdTargetOsTest 의 문자열 규칙으로만 덮여 있다. + */ + @Test + @DisplayName("apOS가 null인 실제 캠페인은 전체 노출로 본다") + void mapsNullOsToAll() { + AdpickCampaignResponse nullOs = campaigns.get(5); + + assertThat(nullOs.os()).isNull(); + assertThat(AdTargetOs.fromAdpick(nullOs.os())).isEqualTo(AdTargetOs.ALL); + } + + @Test + @DisplayName("apOS 실제 값 Both·Android가 타깃 OS로 옮겨진다") + void mapsRealOsValues() { + assertThat(AdTargetOs.fromAdpick(campaigns.get(0).os())).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick(campaigns.get(1).os())).isEqualTo(AdTargetOs.ANDROID); + } + + @Test + @DisplayName("헤드라인·프로모션 문구가 모두 빈 캠페인도 렌더 대상이다. 보조 문구만 비게 둔다") + void treatsEmptyCopyAsRenderable() { + AdpickCampaignResponse noCopy = campaigns.get(2); + + assertThat(noCopy.headline()).isEmpty(); + assertThat(noCopy.promoText()).isEmpty(); + assertThat(noCopy.isRenderable()).isTrue(); + } + + @Test + @DisplayName("잔여가 0이면 게재 대상이 아니다") + void marksSoldOutCampaignAsNotServable() { + assertThat(campaigns.get(3).hasRemaining()).isFalse(); + } + + @Test + @DisplayName("아이콘이 없으면 배너를 그릴 수 없으므로 렌더 대상에서 뺀다") + void excludesCampaignWithoutIcon() { + assertThat(campaigns.get(4).iconUrl()).isNull(); + assertThat(campaigns.get(4).isRenderable()).isFalse(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java new file mode 100644 index 00000000..63a27239 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/controller/AdApiIntegrationTest.java @@ -0,0 +1,203 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import java.time.LocalDate; +import java.time.ZoneId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 제휴 광고 배너가 앱까지 실제로 나가는지 확인한다. + * 조회 → 노출 집계 → 클릭 리다이렉트 → 공개 랜딩까지 한 흐름으로 본다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdApiIntegrationTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AdCreativeRepository adCreativeRepository; + + @Autowired + private AdImpressionDailyRepository adImpressionDailyRepository; + + @BeforeEach + void setUp() { + adImpressionDailyRepository.deleteAll(); + adCreativeRepository.deleteAll(); + } + + @Test + @DisplayName("게재 중인 쿠팡 소재는 배너 렌더에 필요한 값과 클릭 URL을 모두 담아 나간다") + void getAds_returnsServableCoupangCreative() throws Exception { + adCreativeRepository.save(coupang("cpg00001", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/api/v1/ads").param("slot", "HOME_FEED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].code").value("cpg00001")) + .andExpect(jsonPath("$.data[0].network").value("COUPANG")) + .andExpect(jsonPath("$.data[0].title").value("무선 이어폰")) + .andExpect(jsonPath("$.data[0].imageUrl").value("https://img.example.com/1.jpg")) + .andExpect(jsonPath("$.data[0].ctaText").value("구매하러 가기")) + .andExpect(jsonPath("$.data[0].clickUrl").value("https://ad.picke.store/c/cpg00001")) + .andExpect(jsonPath("$.data[0].label").value("광고")); + } + + @Test + @DisplayName("게재 가능한 소재가 없으면 빈 배열을 준다. 앱은 이때 지면을 숨긴다") + void getAds_returnsEmptyWhenNothingServable() throws Exception { + adCreativeRepository.save(coupang("cpg00002", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.PAUSED)); + + mockMvc.perform(get("/api/v1/ads").param("slot", "HOME_FEED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + } + + @Test + @DisplayName("iOS 요청에 Android 전용 애드픽 소재는 나가지 않는다") + void getAds_excludesCreativeOfOtherOs() throws Exception { + adCreativeRepository.save(coupang("apk00001", AdSlotCode.BATTLE_RESULT_BOTTOM, + AdTargetOs.ANDROID, AdStatus.ACTIVE)); + + mockMvc.perform(get("/api/v1/ads") + .param("slot", "BATTLE_RESULT_BOTTOM") + .param("os", "IOS")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + + mockMvc.perform(get("/api/v1/ads") + .param("slot", "BATTLE_RESULT_BOTTOM") + .param("os", "ANDROID")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } + + @Test + @DisplayName("노출 집계는 같은 소재를 두 번 보내면 같은 날짜 행에 누적된다") + void recordImpressions_accumulatesIntoSameDailyRow() throws Exception { + AdCreative creative = adCreativeRepository.save( + coupang("cpg00003", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + for (int i = 0; i < 2; i++) { + mockMvc.perform(post("/api/v1/ads/impressions") + .contentType("application/json") + .content("{\"codes\":[\"cpg00003\"]}")) + .andExpect(status().isOk()); + } + + assertThat(adImpressionDailyRepository + .findByCreativeIdAndSlotAndStatDate(creative.getId(), AdSlotCode.HOME_FEED, LocalDate.now(KST))) + .isPresent() + .get() + .extracting(daily -> daily.getImpressions()) + .isEqualTo(2L); + } + + @Test + @DisplayName("필수 파라미터가 빠지면 500이 아니라 400을 준다") + void findAds_missingRequiredParamIsBadRequest() throws Exception { + // 전역 핸들러가 MissingServletRequestParameterException 을 잡지 못해 500 으로 새던 자리다. + // 광고 전용 문제가 아니라 필수 쿼리 파라미터를 쓰는 모든 API 가 같이 영향을 받는다. + mockMvc.perform(get("/api/v1/ads")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("노출 집계는 한 번에 밀어 넣을 수 있는 소재 수를 제한한다") + void recordImpressions_rejectsOversizedBatch() throws Exception { + String codes = java.util.stream.IntStream.range(0, 21) + .mapToObj(i -> "\"code%02d\"".formatted(i)) + .collect(java.util.stream.Collectors.joining(",")); + + mockMvc.perform(post("/api/v1/ads/impressions") + .contentType("application/json") + .content("{\"codes\":[" + codes + "]}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("클릭 숏링크는 지면별 subId를 붙인 제휴 링크로 리다이렉트한다") + void click_redirectsToAffiliateLinkWithSubId() throws Exception { + adCreativeRepository.save(coupang("cpg00004", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/c/cpg00004")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", + "https://link.coupang.com/a/ABCDE?lptag=AF6830373&subId=HOME_FEED_cpg00004")); + } + + @Test + @DisplayName("없는 코드로 들어오면 404 대신 광고 랜딩으로 흘려보낸다") + void click_fallsBackToLandingWhenCodeMissing() throws Exception { + mockMvc.perform(get("/c/nosuchcode")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", "https://ad.picke.store/")); + } + + @Test + @DisplayName("ad.picke.store 루트는 매체 심사용 공개 페이지에 소재를 그린다") + void landing_rendersCreativesOnAdHost() throws Exception { + adCreativeRepository.save(coupang("cpg00005", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/").with(request -> { + request.setServerName("ad.picke.store"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("무선 이어폰"))) + .andExpect(content().string( + org.hamcrest.Matchers.containsString("https://ad.picke.store/c/cpg00005"))); + } + + @Test + @DisplayName("광고 도메인이 아닌 Host의 루트에는 광고 페이지가 뜨지 않는다") + void landing_doesNotRenderOnApiHost() throws Exception { + adCreativeRepository.save(coupang("cpg00006", AdSlotCode.HOME_FEED, AdTargetOs.ALL, AdStatus.ACTIVE)); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().string("PICKE")); + } + + private AdCreative coupang(String code, AdSlotCode slot, AdTargetOs targetOs, AdStatus status) { + return AdCreative.builder() + .code(code) + .network(AdNetwork.COUPANG) + .slot(slot) + .title("무선 이어폰") + .subtitle("리뷰 1만 개 이상") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/ABCDE?lptag=AF6830373") + .status(status) + .weight(1) + .targetOs(targetOs) + .build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/controller/AdControllerTest.java b/src/test/java/com/swyp/picke/domain/ad/controller/AdControllerTest.java new file mode 100644 index 00000000..a9ce0e57 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/controller/AdControllerTest.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.ad.controller; + +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.service.AdQueryService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class AdControllerTest { + @Test + void previewUsesExactUserResponseWithoutRecordingImpressions() throws Exception { + AdQueryService service = mock(AdQueryService.class); + AdResponse ad = new AdResponse("sample01", AdNetwork.ADPICK, "사용자 광고", "상품 설명", + "https://example.com/image.jpg", "구매하러 가기", "https://ad.picke.store/c/sample01", "광고"); + when(service.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.IOS, 5)).thenReturn(List.of(ad)); + MockMvcBuilders.standaloneSetup(new AdController(service)).build() + .perform(get("/api/v1/ads").accept(org.springframework.http.MediaType.APPLICATION_JSON).param("slot", "HOME_FEED").param("os", "IOS").param("size", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].code").value("sample01")) + .andExpect(jsonPath("$.data[0].title").value("사용자 광고")) + .andExpect(jsonPath("$.data[0].label").value("광고")); + verify(service).findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.IOS, 5); + verifyNoMoreInteractions(service); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java b/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java new file mode 100644 index 00000000..2f638456 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/entity/AdCheckConstraintTest.java @@ -0,0 +1,84 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.hibernate.annotations.Check; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * enum에 값을 추가하고 CHECK 제약 갱신을 잊으면 운영에서 INSERT가 조용히 막힌다. + * 제약 문자열과 enum 상수가 어긋나는 순간 여기서 먼저 깨지게 한다. + */ +class AdCheckConstraintTest { + + private static final Pattern QUOTED = Pattern.compile("'([^']+)'"); + + private Set valuesOf(Class entity, String constraintName) { + String constraints = Arrays.stream(entity.getAnnotationsByType(Check.class)) + .filter(check -> check.name().equals(constraintName)) + .map(Check::constraints) + .findFirst() + .orElseThrow(() -> new AssertionError( + entity.getSimpleName() + "에 " + constraintName + " CHECK 제약이 없습니다.")); + + Matcher matcher = QUOTED.matcher(constraints); + return matcher.results() + .map(result -> result.group(1)) + .collect(Collectors.toSet()); + } + + private Set namesOf(Class> type) { + return Arrays.stream(type.getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toSet()); + } + + @Test + @DisplayName("ad_creatives의 network CHECK 제약이 AdNetwork 전체와 일치한다") + void adCreativesNetworkCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_network")) + .isEqualTo(namesOf(AdNetwork.class)); + } + + @Test + @DisplayName("ad_creatives의 status CHECK 제약이 AdStatus 전체와 일치한다") + void adCreativesStatusCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_status")) + .isEqualTo(namesOf(AdStatus.class)); + } + + @Test + @DisplayName("ad_creatives의 source CHECK 제약이 AdSource 전체와 일치한다") + void adCreativesSourceCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_source")) + .isEqualTo(namesOf(AdSource.class)); + } + + @Test + @DisplayName("ad_creatives의 target_os CHECK 제약이 AdTargetOs 전체와 일치한다") + void adCreativesTargetOsCheckMatchesEnum() { + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_target_os")) + .isEqualTo(namesOf(AdTargetOs.class)); + } + + @Test + @DisplayName("slot CHECK 제약이 세 테이블 모두 AdSlotCode 전체와 일치한다") + void slotChecksMatchEnum() { + Set slots = namesOf(AdSlotCode.class); + + assertThat(valuesOf(AdCreative.class, "ck_ad_creatives_slot")).isEqualTo(slots); + assertThat(valuesOf(AdClickLog.class, "ck_ad_click_logs_slot")).isEqualTo(slots); + assertThat(valuesOf(AdImpressionDaily.class, "ck_ad_impression_daily_slot")).isEqualTo(slots); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java new file mode 100644 index 00000000..42379efd --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/entity/AdCreativeTest.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.ad.entity; + +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdCreativeTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 1, 12, 0); + + private AdCreative creative(AdStatus status, LocalDateTime startsAt, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .startsAt(startsAt) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("ACTIVE이고 기간이 열려 있으면 게재한다") + void isServable_true() { + assertThat(creative(AdStatus.ACTIVE, null, null).isServable(NOW)).isTrue(); + } + + @Test + @DisplayName("PAUSED와 DRAFT는 기간과 무관하게 게재하지 않는다") + void isServable_falseWhenNotActive() { + assertThat(creative(AdStatus.PAUSED, null, null).isServable(NOW)).isFalse(); + assertThat(creative(AdStatus.DRAFT, null, null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("시작 전 소재는 게재하지 않는다") + void isServable_falseBeforeStart() { + assertThat(creative(AdStatus.ACTIVE, NOW.plusDays(1), null).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료된 소재는 게재하지 않는다") + void isServable_falseAfterEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW.minusSeconds(1)).isServable(NOW)).isFalse(); + } + + @Test + @DisplayName("종료 시각과 정확히 같은 순간까지는 게재한다") + void isServable_trueAtExactEnd() { + assertThat(creative(AdStatus.ACTIVE, null, NOW).isServable(NOW)).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java b/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java new file mode 100644 index 00000000..e5f41f06 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/enums/AdTargetOsTest.java @@ -0,0 +1,42 @@ +package com.swyp.picke.domain.ad.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdTargetOsTest { + + @Test + @DisplayName("애드픽 apOS 값을 우리 enum으로 옮긴다") + void fromAdpick() { + assertThat(AdTargetOs.fromAdpick("Android")).isEqualTo(AdTargetOs.ANDROID); + assertThat(AdTargetOs.fromAdpick("android")).isEqualTo(AdTargetOs.ANDROID); + assertThat(AdTargetOs.fromAdpick("iOS")).isEqualTo(AdTargetOs.IOS); + assertThat(AdTargetOs.fromAdpick("iPhone")).isEqualTo(AdTargetOs.IOS); + } + + @Test + @DisplayName("OS가 비었거나 모르는 값이면 전체 노출로 둔다") + void fromAdpick_unknown() { + assertThat(AdTargetOs.fromAdpick(null)).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick("")).isEqualTo(AdTargetOs.ALL); + assertThat(AdTargetOs.fromAdpick("Tizen")).isEqualTo(AdTargetOs.ALL); + } + + @Test + @DisplayName("ALL 소재는 어느 OS에나 나가고, OS 지정 소재는 같은 OS에만 나간다") + void matches() { + assertThat(AdTargetOs.ALL.matches(AdTargetOs.IOS)).isTrue(); + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.ANDROID)).isTrue(); + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.IOS)).isFalse(); + assertThat(AdTargetOs.IOS.matches(AdTargetOs.ANDROID)).isFalse(); + } + + @Test + @DisplayName("요청 OS를 모르면(ALL) 모든 소재를 후보로 둔다") + void matches_requestAll() { + assertThat(AdTargetOs.ANDROID.matches(AdTargetOs.ALL)).isTrue(); + assertThat(AdTargetOs.IOS.matches(AdTargetOs.ALL)).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java new file mode 100644 index 00000000..36d89c41 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/AdpickLinkBuilderTest.java @@ -0,0 +1,53 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdpickLinkBuilderTest { + + private static final String LANDING = "https://adpick.co.kr/?ac=offer&tac=campaign&id=123"; + + private AdCreative creative() { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("앱 설치하고 포인트 받기") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl(LANDING) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + private AdpickLinkBuilder builder(String subIdParam) { + AdpickLinkBuilder builder = new AdpickLinkBuilder(); + ReflectionTestUtils.setField(builder, "subIdParam", subIdParam); + return builder; + } + + @Test + @DisplayName("파라미터명이 비어 있으면 원본 링크를 그대로 넘긴다") + void build_passThroughWhenParamNotConfigured() { + assertThat(builder("").build(creative())).isEqualTo(LANDING); + assertThat(builder(null).build(creative())).isEqualTo(LANDING); + } + + @Test + @DisplayName("파라미터명을 채우면 배포 없이 지면별 추적값이 붙는다") + void build_mergesSubIdWhenConfigured() { + String url = builder("subid").build(creative()); + + assertThat(url).contains("subid=BATTLE_RESULT_BOTTOM_abc12345"); + assertThat(url).contains("ac=offer"); + assertThat(url).contains("id=123"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java new file mode 100644 index 00000000..dea9512f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/link/CoupangLinkBuilderTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.ad.link; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoupangLinkBuilderTest { + + private final CoupangLinkBuilder builder = new CoupangLinkBuilder(); + + private AdCreative creative(String landingUrl) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl(landingUrl) + .status(AdStatus.ACTIVE) + .weight(1) + .build(); + } + + @Test + @DisplayName("쿼리스트링이 이미 있는 제휴 링크에도 subId를 병합한다") + void build_mergesSubIdIntoExistingQueryString() { + String url = builder.build(creative("https://link.coupang.com/re/AFF?lptag=AF6830373&pageKey=123")); + + assertThat(url).contains("lptag=AF6830373"); + assertThat(url).contains("pageKey=123"); + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("??"); + } + + @Test + @DisplayName("쿼리스트링이 없는 링크에는 subId를 새로 붙인다") + void build_appendsSubIdWhenNoQueryString() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef")); + + assertThat(url).isEqualTo("https://link.coupang.com/a/abcdef?subId=HOME_FEED_abc12345"); + } + + @Test + @DisplayName("이미 subId가 있으면 우리 값으로 덮어쓴다") + void build_replacesExistingSubId() { + String url = builder.build(creative("https://link.coupang.com/a/abcdef?subId=old")); + + assertThat(url).contains("subId=HOME_FEED_abc12345"); + assertThat(url).doesNotContain("subId=old"); + } + + @Test + @DisplayName("인코딩된 파라미터를 이중 인코딩하지 않는다") + void build_doesNotDoubleEncode() { + String url = builder.build(creative("https://link.coupang.com/a/x?q=%EC%9D%B4%EC%96%B4%ED%8F%B0")); + + assertThat(url).contains("q=%EC%9D%B4%EC%96%B4%ED%8F%B0"); + assertThat(url).doesNotContain("%25"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java new file mode 100644 index 00000000..e542b577 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdClickServiceTest.java @@ -0,0 +1,82 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.link.AffiliateLinkResolver; +import com.swyp.picke.domain.ad.link.CoupangLinkBuilder; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.service.AdClickService.AdClickTarget; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdClickServiceTest { + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdClickLogRepository adClickLogRepository; + + private AdClickService adClickService() { + return new AdClickService( + adCreativeRepository, + adClickLogRepository, + new AffiliateLinkResolver(List.of(new CoupangLinkBuilder()))); + } + + private AdCreative creative(AdStatus status, LocalDateTime endsAt) { + return AdCreative.builder() + .code("abc12345") + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/abcdef") + .status(status) + .weight(1) + .endsAt(endsAt) + .build(); + } + + @Test + @DisplayName("없는 코드는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenCodeMissing() { + when(adCreativeRepository.findByCode("nope0000")).thenReturn(Optional.empty()); + + assertThat(adClickService().resolveTarget("nope0000")).isEmpty(); + } + + @Test + @DisplayName("게재가 끝난 소재는 이동 대상을 주지 않는다") + void resolveTarget_emptyWhenExpired() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, LocalDateTime.now().minusDays(1)))); + + assertThat(adClickService().resolveTarget("abc12345")).isEmpty(); + } + + @Test + @DisplayName("게재 중인 소재는 매체 규칙이 적용된 최종 URL을 준다") + void resolveTarget_returnsResolvedUrl() { + when(adCreativeRepository.findByCode("abc12345")) + .thenReturn(Optional.of(creative(AdStatus.ACTIVE, null))); + + AdClickTarget target = adClickService().resolveTarget("abc12345").orElseThrow(); + + assertThat(target.slot()).isEqualTo(AdSlotCode.HOME_FEED); + assertThat(target.redirectUrl()).contains("subId=HOME_FEED_abc12345"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java new file mode 100644 index 00000000..ace9f9ae --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdQueryServiceTest.java @@ -0,0 +1,234 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.dto.response.AdResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdQueryServiceTest { + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdImpressionRecorder adImpressionRecorder; + + @InjectMocks + private AdQueryService adQueryService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adQueryService, "adBaseUrl", "https://ad.picke.store"); + } + + private AdCreative creative(String code, AdStatus status, int weight, + LocalDateTime startsAt, LocalDateTime endsAt) { + return AdCreative.builder() + .code(code) + .network(AdNetwork.COUPANG) + .slot(AdSlotCode.HOME_FEED) + .title("무선 이어폰") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("구매하러 가기") + .landingUrl("https://link.coupang.com/a/" + code) + .status(status) + .weight(weight) + .startsAt(startsAt) + .endsAt(endsAt) + .targetOs(AdTargetOs.ALL) + .build(); + } + + @Test + @DisplayName("게재 기간이 지난 소재는 응답에서 제외한다") + void findServableAds_excludesExpired() { + AdCreative live = creative("live0001", AdStatus.ACTIVE, 1, null, null); + AdCreative expired = creative("dead0001", AdStatus.ACTIVE, 1, null, LocalDateTime.now().minusDays(1)); + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(live, expired)); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 5); + + assertThat(result).hasSize(1); + assertThat(result.get(0).code()).isEqualTo("live0001"); + } + + @Test + @DisplayName("게재 가능한 소재가 없으면 빈 목록을 준다. 광고 없음은 오류가 아니다") + void findServableAds_returnsEmpty() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of()); + + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 1)).isEmpty(); + } + + @Test + @DisplayName("clickUrl은 광고 도메인의 짧은 코드 경로로 만든다") + void findServableAds_buildsClickUrl() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(creative("abc12345", AdStatus.ACTIVE, 1, null, null))); + + AdResponse response = adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 1).get(0); + + assertThat(response.clickUrl()).isEqualTo("https://ad.picke.store/c/abc12345"); + assertThat(response.label()).isEqualTo("광고"); + } + + @Test + @DisplayName("요청 개수만큼만 주고 같은 소재를 두 번 담지 않는다") + void findServableAds_limitsSizeWithoutDuplicates() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of( + creative("aaaa1111", AdStatus.ACTIVE, 1, null, null), + creative("bbbb2222", AdStatus.ACTIVE, 1, null, null), + creative("cccc3333", AdStatus.ACTIVE, 1, null, null))); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 2); + + assertThat(result).hasSize(2); + assertThat(result.stream().map(AdResponse::code).distinct()).hasSize(2); + } + + @Test + @DisplayName("가중치가 큰 소재가 확연히 자주 뽑힌다") + void findServableAds_weightedRotation() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of( + creative("heavy001", AdStatus.ACTIVE, 99, null, null), + creative("light001", AdStatus.ACTIVE, 1, null, null))); + + long heavyPicks = java.util.stream.IntStream.range(0, 500) + .mapToObj(i -> adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 1).get(0).code()) + .filter("heavy001"::equals) + .count(); + + assertThat(heavyPicks).isGreaterThan(400); + } + + @Test + @DisplayName("요청 OS와 맞지 않는 소재는 제외한다") + void findServableAds_filtersByTargetOs() { + AdCreative android = creative("aos00001", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(android, "targetOs", AdTargetOs.ANDROID); + AdCreative ios = creative("ios00001", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(ios, "targetOs", AdTargetOs.IOS); + AdCreative every = creative("all00001", AdStatus.ACTIVE, 1, null, null); + + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(android, ios, every)); + + List result = adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.IOS, 10); + + assertThat(result).extracting(AdResponse::code).containsExactlyInAnyOrder("ios00001", "all00001"); + } + + private AdCreative syncedAdpick(String code, String externalId) { + AdCreative ad = creative(code, AdStatus.ACTIVE, 100, null, null); + ReflectionTestUtils.setField(ad, "network", AdNetwork.ADPICK); + ReflectionTestUtils.setField(ad, "source", AdSource.ADPICK_API); + ReflectionTestUtils.setField(ad, "externalId", externalId); + return ad; + } + + @Test + void excludesInstallCampaignsEvenWhenShoppingCannotFillRequestedSize() { + var shopping = syncedAdpick("shop0001", "sh012345abcd"); + var install = syncedAdpick("app00001", "ukjd9nru"); + var signup = syncedAdpick("join0001", "hmvdx3t3"); + var coupang = creative("coup0001", AdStatus.ACTIVE, 1, null, null); + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(shopping, install, signup, coupang)); + + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ALL, 10)) + .extracting(AdResponse::code).containsExactlyInAnyOrder("shop0001", "coup0001"); + } + + @Test + void doesNotFallbackToInstallAdsWhenThereAreNoShoppingProducts() { + when(adCreativeRepository.findAllBySlotAndStatus(AdSlotCode.HOME_FEED, AdStatus.ACTIVE)) + .thenReturn(List.of(syncedAdpick("app00001", "ukjd9nru"), + syncedAdpick("bad00001", null))); + assertThat(adQueryService.findServableAds(AdSlotCode.HOME_FEED, AdTargetOs.ANDROID, 10)).isEmpty(); + } + + @Test + void landingAlsoExcludesInstallCampaigns() { + when(adCreativeRepository.findAllByStatusOrderByIdDesc(AdStatus.ACTIVE)) + .thenReturn(List.of(syncedAdpick("shop0001", "sh012345abcd"), + syncedAdpick("app00001", "ukjd9nru"))); + assertThat(adQueryService.findLandingAds()).extracting(AdResponse::code) + .containsExactly("shop0001"); + } + + @Test + @DisplayName("노출 집계는 같은 날 반복 호출하면 기존 행을 누적한다") + void recordImpressions_incrementsExistingRow() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(true); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, never()).insert(anyLong(), any(), any()); + } + + @Test + @DisplayName("그날 첫 노출이면 집계 행을 새로 만든다") + void recordImpressions_insertsWhenAbsent() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(false); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, times(1)) + .insert(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + } + + @Test + @DisplayName("동시에 같은 집계 행을 만들면 갱신으로 되돌린다. 되돌리기는 실패한 삽입 트랜잭션 밖에서 돈다") + void recordImpressions_retriesOnConcurrentInsert() { + AdCreative creative = creative("abc12345", AdStatus.ACTIVE, 1, null, null); + ReflectionTestUtils.setField(creative, "id", 7L); + when(adCreativeRepository.findAllByCodeIn(List.of("abc12345"))).thenReturn(List.of(creative)); + when(adImpressionRecorder.increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class))) + .thenReturn(false, true); + doThrow(new DataIntegrityViolationException("duplicate")) + .when(adImpressionRecorder).insert(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + + adQueryService.recordImpressions(List.of("abc12345")); + + verify(adImpressionRecorder, times(2)) + .increment(eq(7L), eq(AdSlotCode.HOME_FEED), any(LocalDate.class)); + } +} diff --git a/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java new file mode 100644 index 00000000..b8f81ab5 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/ad/service/AdpickCampaignSyncServiceTest.java @@ -0,0 +1,286 @@ +package com.swyp.picke.domain.ad.service; + +import com.swyp.picke.domain.ad.client.AdpickCampaignClient; +import com.swyp.picke.domain.ad.client.AdpickShoppingClient; +import com.swyp.picke.domain.ad.client.AdpickShoppingResponse; +import com.swyp.picke.domain.ad.client.AdpickCampaignResponse; +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdpickCampaignSyncServiceTest { + + private static final String CTA = "설치하고 받기"; + + @Mock + private AdpickCampaignClient adpickCampaignClient; + @Mock + private AdpickShoppingClient adpickShoppingClient; + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdCreativeCodeGenerator adCreativeCodeGenerator; + + @InjectMocks + private AdpickCampaignSyncService syncService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(syncService, "slot", AdSlotCode.BATTLE_RESULT_BOTTOM); + ReflectionTestUtils.setField(syncService, "ctaText", CTA); + ReflectionTestUtils.setField(syncService, "shoppingSlots", List.of(AdSlotCode.HOME_FEED)); + ReflectionTestUtils.setField(syncService, "shoppingCtaText", "구매하러 가기"); + ReflectionTestUtils.setField(syncService, "campaignWeight", 1); + ReflectionTestUtils.setField(syncService, "shoppingWeight", 3); + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adCreativeCodeGenerator.generate()).thenReturn("syn23456"); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of()); + } + + private AdpickCampaignResponse campaign(String offerId, String os, Integer remain) { + return new AdpickCampaignResponse( + offerId, + "리워디 월렛 - 금테크 지갑", + "금 모으기 앱테크", + "설명 텍스트", + Map.of("icon", "https://play-lh.googleusercontent.com/icon.png"), + "https://deg.kr/39e859f", + os, + remain + ); + } + + private AdCreative managed(String externalId, AdStatus status) { + return AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("예전 제목") + .imageUrl("https://img.example.com/old.png") + .ctaText(CTA) + .landingUrl("https://deg.kr/old") + .status(status) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId(externalId) + .targetOs(AdTargetOs.ANDROID) + .build(); + } + + @Test + @DisplayName("affId가 없으면 호출하지 않고 건너뛴다") + void sync_skipsWhenNotConfigured() { + when(adpickCampaignClient.isConfigured()).thenReturn(false); + + assertThat(syncService.sync()).isZero(); + verify(adpickCampaignClient, never()).fetchCampaigns(); + } + + @Test + @DisplayName("새 캠페인을 소재로 만들면서 매체·지면·OS를 채운다") + void sync_createsCreativeFromCampaign() { + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 120))); + + syncService.sync(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository).save(captor.capture()); + AdCreative saved = captor.getValue(); + + assertThat(saved.getNetwork()).isEqualTo(AdNetwork.ADPICK); + assertThat(saved.getSource()).isEqualTo(AdSource.ADPICK_API); + assertThat(saved.getExternalId()).isEqualTo("16b04"); + assertThat(saved.getSlot()).isEqualTo(AdSlotCode.BATTLE_RESULT_BOTTOM); + assertThat(saved.getTargetOs()).isEqualTo(AdTargetOs.ANDROID); + assertThat(saved.getStatus()).isEqualTo(AdStatus.ACTIVE); + assertThat(saved.getLandingUrl()).isEqualTo("https://deg.kr/39e859f"); + assertThat(saved.getImageUrl()).isEqualTo("https://play-lh.googleusercontent.com/icon.png"); + assertThat(saved.getCtaText()).isEqualTo(CTA); + } + + @Test + @DisplayName("잔여 수량이 없으면 게재하지 않는다") + void sync_doesNotServeExhaustedCampaign() { + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 0))); + + syncService.sync(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(AdStatus.DRAFT); + } + + @Test + @DisplayName("이미지나 추적 링크가 없는 캠페인은 건너뛴다") + void sync_skipsUnrenderableCampaign() { + AdpickCampaignResponse broken = new AdpickCampaignResponse( + "16b04", "제목", null, null, Map.of(), null, "Android", 10); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(broken)); + + assertThat(syncService.sync()).isZero(); + verify(adCreativeRepository, never()).save(any()); + } + + @Test + @DisplayName("기존 소재는 내용을 갱신하고 새로 만들지 않는다") + void sync_updatesExistingCreative() { + AdCreative existing = managed("16b04", AdStatus.ACTIVE); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(existing)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 50))); + + syncService.sync(); + + verify(adCreativeRepository, never()).save(any()); + assertThat(existing.getTitle()).isEqualTo("리워디 월렛 - 금테크 지갑"); + assertThat(existing.getLandingUrl()).isEqualTo("https://deg.kr/39e859f"); + } + + @Test + @DisplayName("어드민이 꺼둔 소재는 동기화가 다시 켜지 않는다") + void sync_keepsAdminPause() { + AdCreative paused = managed("16b04", AdStatus.PAUSED); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(paused)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 120))); + + syncService.sync(); + + assertThat(paused.getStatus()).isEqualTo(AdStatus.PAUSED); + } + + @Test + @DisplayName("피드에서 사라진 캠페인은 지우지 않고 내린다") + void sync_retiresMissingCampaign() { + AdCreative gone = managed("old01", AdStatus.ACTIVE); + when(adCreativeRepository.findAllBySource(AdSource.ADPICK_API)).thenReturn(List.of(gone)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("16b04", "Android", 10))); + + syncService.sync(); + + assertThat(gone.getStatus()).isEqualTo(AdStatus.DRAFT); + verify(adCreativeRepository, never()).delete(any()); + } + + private AdpickShoppingResponse product(String name, String buyUrl) { + return new AdpickShoppingResponse(name, "https://img.example.com/p.jpg", "트립닷컴", "최대 4.2%", buyUrl); + } + + @Test + @DisplayName("쇼핑·핫딜 상품도 소재로 담는다. 앱 캠페인이 7건뿐이라 지면을 채우려면 이쪽이 필요하다") + void sync_bringsShoppingProducts() { + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of()); + when(adpickShoppingClient.isConfigured()).thenReturn(true); + when(adpickShoppingClient.fetchProducts()).thenReturn(List.of( + product("호텔 A", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=a"), + product("호텔 B", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=b"))); + + assertThat(syncService.sync()).isEqualTo(2); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()).extracting(AdCreative::getCtaText) + .containsOnly("구매하러 가기"); + assertThat(captor.getAllValues()).extracting(AdCreative::getExternalId) + .allMatch(id -> id.startsWith("sh")); + assertThat(captor.getAllValues()).extracting(AdCreative::getTargetOs) + .containsOnly(AdTargetOs.ALL); + } + + @Test + @DisplayName("쇼핑 상품은 모든 쇼핑 지면에 각각 담긴다. 지면 하나에만 두면 지면당 재고가 쪼개진다") + void sync_registersShoppingProductOnEverySlot() { + ReflectionTestUtils.setField(syncService, "shoppingSlots", + List.of(AdSlotCode.HOME_FEED, AdSlotCode.CHAT_ROOM_INLINE, AdSlotCode.PROFILE_BOTTOM)); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of()); + when(adpickShoppingClient.isConfigured()).thenReturn(true); + when(adpickShoppingClient.fetchProducts()).thenReturn(List.of( + product("호텔 A", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=a"))); + + assertThat(syncService.sync()).isEqualTo(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository, times(3)).save(captor.capture()); + assertThat(captor.getAllValues()).extracting(AdCreative::getSlot) + .containsExactlyInAnyOrder(AdSlotCode.HOME_FEED, AdSlotCode.CHAT_ROOM_INLINE, + AdSlotCode.PROFILE_BOTTOM); + // 지면별로 노출·클릭을 따로 세려면 식별자도 지면별로 달라야 한다. + assertThat(captor.getAllValues()).extracting(AdCreative::getExternalId) + .doesNotHaveDuplicates() + .allMatch(id -> id.matches("sh[0-9a-f]{10}")); + } + + @Test + @DisplayName("같은 상품이 쇼핑과 핫딜에 겹쳐 실려도 한 번만 담는다") + void sync_deduplicatesProductsAcrossFeeds() { + String buyUrl = "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=same"; + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of()); + when(adpickShoppingClient.isConfigured()).thenReturn(true); + when(adpickShoppingClient.fetchProducts()).thenReturn(List.of( + product("같은 상품", buyUrl), product("같은 상품", buyUrl))); + + syncService.sync(); + + verify(adCreativeRepository, times(1)).save(any(AdCreative.class)); + } + + @Test + @DisplayName("애드픽 설정이 없으면 쇼핑도 건너뛴다") + void sync_skipsShoppingWhenNotConfigured() { + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of()); + when(adpickShoppingClient.isConfigured()).thenReturn(false); + + assertThat(syncService.sync()).isZero(); + + verify(adpickShoppingClient, never()).fetchProducts(); + } + + @Test + @DisplayName("쇼핑 상품은 앱 캠페인보다 높은 가중치로 담긴다. 앱 캠페인 7건이 반복 노출되지 않게 한다") + void sync_weightsShoppingHigher() { + when(adpickCampaignClient.isConfigured()).thenReturn(true); + when(adpickCampaignClient.fetchCampaigns()).thenReturn(List.of(campaign("aaaa1", "Both", 5))); + when(adpickShoppingClient.isConfigured()).thenReturn(true); + when(adpickShoppingClient.fetchProducts()).thenReturn(List.of( + product("상품", "https://adpick.co.kr/apis/goshopping.php?affid=ab2c41&offer=1&url=w"))); + + syncService.sync(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdCreative.class); + verify(adCreativeRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()).extracting(AdCreative::getExternalId, AdCreative::getWeight) + .containsExactlyInAnyOrder(tuple("aaaa1", 1), + tuple(captor.getAllValues().stream() + .map(AdCreative::getExternalId) + .filter(id -> id.startsWith("sh")) + .findFirst().orElseThrow(), 3)); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java new file mode 100644 index 00000000..491aa456 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitAccountReportClientTest.java @@ -0,0 +1,117 @@ +package com.swyp.picke.domain.admin.adfit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.net.URI; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class AdfitAccountReportClientTest { + private final LocalDate from = LocalDate.of(2026, 9, 8); + private final LocalDate to = LocalDate.of(2026, 9, 10); + + @Test void springCreatesClientWithoutConfiguredCredentials() { + try (var context = new org.springframework.context.annotation.AnnotationConfigApplicationContext()) { + context.registerBean(AdfitHttpTransport.class, + () -> new CapturingTransport(response(200, "application/json", "[]"))); + context.registerBean(AdfitSessionCookieStore.class, + () -> org.mockito.Mockito.mock(AdfitSessionCookieStore.class)); + context.register(AdfitAccountReportClient.class); + context.refresh(); + assertThat(context.getBean(AdfitAccountReportClient.class)).isNotNull(); + } + } + + @Test void returnsNotConfiguredWithoutCookie() { + var client = client("", response(200, "application/json", "[]")); + var result = client.fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.NOT_CONFIGURED); + assertThat(result.days()).hasSize(3); + assertThat(result.days()).extracting(AdfitReport.AccountDay::revenue).containsOnlyNulls(); + } + + @Test void fetchesConsoleDailyRevenueWithIsoDateParameters() { + var transport = new CapturingTransport(response(200, "application/json", """ + [ + {"reportDate":"2026-09-10","profit":null,"ctr":1.5,"ecpm":2,"fillRate":3,"winFillRate":4}, + {"reportDate":"2026-09-09","profit":5}, + {"reportDate":"2026-09-08","profit":0} + ] + """)); + var client = new AdfitAccountReportClient(() -> "KAKAO=secret", transport, new ObjectMapper()); + var result = client.fetch(from, to, 3); + assertThat(transport.uri.toString()).contains("dayType=DAY", "startDate=2026-09-08", "endDate=2026-09-10"); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.CONNECTED); + assertThat(result.fetchedAt()).isNotNull(); + assertThat(result.reportedDays()).isEqualTo(2); + assertThat(result.revenue()).isEqualByComparingTo("5"); + assertThat(result.roi()).isNull(); + assertThat(result.days()).extracting(AdfitReport.AccountDay::date) + .containsExactly(to, LocalDate.of(2026, 9, 9), from); + assertThat(result.days().getFirst().revenue()).isNull(); + assertThat(result.days().getFirst().ctr()).isEqualByComparingTo("1.5"); + assertThat(result.days().get(1).revenue()).isEqualByComparingTo("5"); + assertThat(result.days().get(2).revenue()).isEqualByComparingTo("0"); + } + + @Test void treatsAuthFailuresRedirectsAndHtmlAsReconnectRequired() { + assertReconnectRequired(response(401, "application/json", "{}")); + assertReconnectRequired(response(419, "application/json", "{}")); + assertReconnectRequired(response(302, "text/plain", "")); + assertReconnectRequired(response(200, "text/html", "login")); + } + + @Test void rejectsUnavailableAndInvalidSchemaWithoutFabricatingRevenue() { + assertUnavailable(response(500, "application/json", "{}")); + assertUnavailable(response(200, "text/plain", "[]")); + assertUnavailable(response(200, "application/json", "{}")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-10\"}]")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-10\",\"profit\":\"5\"}]")); + assertUnavailable(response(200, "application/json", "[{\"reportDate\":\"2026-09-11\",\"profit\":5}]")); + assertUnavailable(response(200, "application/json", """ + [ + {"reportDate":"2026-09-10","profit":5}, + {"reportDate":"2026-09-10","profit":6} + ] + """)); + } + + private void assertReconnectRequired(AdfitHttpResponse response) { + var result = client("KAKAO=secret", response).fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.RECONNECT_REQUIRED); + assertThat(result.revenue()).isNull(); + } + + private void assertUnavailable(AdfitHttpResponse response) { + var result = client("KAKAO=secret", response).fetch(from, to, 3); + assertThat(result.status()).isEqualTo(AdfitAccountReportStatus.UNAVAILABLE); + assertThat(result.revenue()).isNull(); + assertThat(result.days()).extracting(AdfitReport.AccountDay::revenue).containsOnlyNulls(); + } + + private AdfitAccountReportClient client(String cookie, AdfitHttpResponse response) { + return new AdfitAccountReportClient(() -> cookie, new CapturingTransport(response), new ObjectMapper()); + } + + private AdfitHttpResponse response(int status, String contentType, String body) { + return new AdfitHttpResponse(status, Map.of("content-type", List.of(contentType)), body); + } + + private static class CapturingTransport implements AdfitHttpTransport { + private final AdfitHttpResponse response; + private URI uri; + + CapturingTransport(AdfitHttpResponse response) { + this.response = response; + } + + @Override + public AdfitHttpResponse get(URI uri, String sessionCookie) { + this.uri = uri; + return response; + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java new file mode 100644 index 00000000..f211d55d --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitReportServiceTest.java @@ -0,0 +1,119 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AdfitReportServiceTest { + @Mock AdfitDailyRepository repository; + @Mock AdfitAccountReportClient accountReportClient; + @Mock AdfitSessionCookieStore sessionCookieStore; + @InjectMocks AdfitReportService service; + private final LocalDate date = LocalDate.of(2026, 9, 1); + + private AdfitDaily day(LocalDate date, String revenue, String cost, AdfitCostBasis basis) { + AdfitDaily day = new AdfitDaily(date, AdfitUnit.NATIVE_WIDE); + day.update(new BigDecimal(revenue), new BigDecimal(cost), basis); + return day; + } + + @Test void calculatesProfitBasedRoiOnceForSharedUnit() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of( + day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS), + day(date.plusDays(1), "100", "50", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); + var result = service.report(date, date.plusDays(1)); + assertThat(result.units()).hasSize(3); + assertThat(result.account().status()).isEqualTo(AdfitAccountReportStatus.NOT_CONFIGURED); + var unit = result.units().getFirst(); + assertThat(unit.placements()).hasSize(3); + assertThat(unit.revenue()).isEqualByComparingTo("300"); + assertThat(unit.cost()).isEqualByComparingTo("150"); + assertThat(unit.roi()).isEqualByComparingTo("100"); + } + + @Test void missingRevenueIsNotZero() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date)).thenReturn(List.of()); + when(accountReportClient.fetch(date, date, 1)).thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)); + var unit = service.report(date, date).units().getFirst(); + assertThat(unit.revenue()).isNull(); + assertThat(unit.cost()).isNull(); + assertThat(unit.roi()).isNull(); + assertThat(unit.reportedDays()).isZero(); + } + + @Test void partialPeriodDoesNotReportRoi() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))) + .thenReturn(List.of(day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); + var unit = service.report(date, date.plusDays(1)).units().getFirst(); + assertThat(unit.revenue()).isEqualByComparingTo("200"); + assertThat(unit.roi()).isNull(); + assertThat(unit.expectedDays()).isEqualTo(2); + } + + @Test void zeroCostHasNoRoiButLossIsNegative() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date)) + .thenReturn(List.of(day(date, "100", "0", AdfitCostBasis.AD_OPERATIONS))) + .thenReturn(List.of(day(date, "50", "100", AdfitCostBasis.AD_OPERATIONS))); + when(accountReportClient.fetch(date, date, 1)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 1)); + assertThat(service.report(date, date).units().getFirst().roi()).isNull(); + assertThat(service.report(date, date).units().getFirst().roi()).isEqualByComparingTo("-50"); + } + + @Test void mixedCostBasesHaveNoRoi() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date.plusDays(1))).thenReturn(List.of( + day(date, "200", "100", AdfitCostBasis.AD_OPERATIONS), + day(date.plusDays(1), "100", "50", AdfitCostBasis.ACQUISITION))); + when(accountReportClient.fetch(date, date.plusDays(1), 2)) + .thenReturn(account(AdfitAccountReportStatus.NOT_CONFIGURED, 2)); + assertThat(service.report(date, date.plusDays(1)).units().getFirst().roi()).isNull(); + } + + @Test void repeatedSaveUpdatesExistingDay() { + var existing = day(date, "100", "10", AdfitCostBasis.AD_OPERATIONS); + when(repository.findByDateAndUnit(date, AdfitUnit.NATIVE_WIDE)).thenReturn(Optional.of(existing)); + service.save(new AdfitDailyRequest(date, AdfitUnit.NATIVE_WIDE, + new BigDecimal("200"), new BigDecimal("20"), AdfitCostBasis.ACQUISITION)); + verify(repository).save(existing); + assertThat(existing.getRevenue()).isEqualByComparingTo("200"); + assertThat(existing.getCostBasis()).isEqualTo(AdfitCostBasis.ACQUISITION); + } + + @Test void fetchesAutomaticAccountReportSeparatelyFromManualUnitReport() { + when(repository.findAllByDateBetweenOrderByDateDesc(date, date)).thenReturn(List.of()); + var account = account(AdfitAccountReportStatus.CONNECTED, 1); + when(accountReportClient.fetch(date, date, 1)).thenReturn(account); + var result = service.report(date, date); + assertThat(result.source()).isEqualTo("MANUAL_CONSOLE"); + assertThat(result.account()).isSameAs(account); + verify(accountReportClient).fetch(date, date, 1); + } + + @Test void rejectsInvalidPeriodsAndFutureEntries() { + assertThatThrownBy(() -> service.report(date.plusDays(1), date)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.report(date, date.plusDays(366))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.save(new AdfitDailyRequest(LocalDate.now().plusDays(2), + AdfitUnit.BANNER, BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS))) + .isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(repository); + verifyNoInteractions(accountReportClient); + } + + private AdfitReport.AccountReport account(AdfitAccountReportStatus status, long expectedDays) { + return new AdfitReport.AccountReport(status, null, 0, expectedDays, null, null, null, List.of()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStoreTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStoreTest.java new file mode 100644 index 00000000..0c30f37a --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdfitSessionCookieStoreTest.java @@ -0,0 +1,104 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdfitSessionCookieStoreTest { + + @Mock AdfitSessionCookieRepository repository; + @InjectMocks AdfitSessionCookieStore store; + + private void environment(String cookie) { + ReflectionTestUtils.setField(store, "environmentCookie", cookie); + } + + @Test + @DisplayName("관리자가 넣은 쿠키가 환경변수보다 앞선다. 만료 때 재배포 없이 갈아끼우는 게 목적이다") + void adminCookieWinsOverEnvironment() { + environment("ENV=old"); + when(repository.findTopByOrderByIdDesc()) + .thenReturn(Optional.of(new AdfitSessionCookie("ADMIN=new"))); + + assertThat(store.current()).isEqualTo("ADMIN=new"); + assertThat(store.status().source()).isEqualTo(AdfitSessionCookieSource.ADMIN_CONSOLE); + } + + @Test + @DisplayName("저장된 쿠키가 없으면 환경변수 값을 쓴다") + void fallsBackToEnvironment() { + environment("ENV=value"); + when(repository.findTopByOrderByIdDesc()).thenReturn(Optional.empty()); + + assertThat(store.current()).isEqualTo("ENV=value"); + assertThat(store.status().source()).isEqualTo(AdfitSessionCookieSource.ENVIRONMENT); + } + + @Test + @DisplayName("양쪽 모두 없으면 미설정이다. 자동 조회가 NOT_CONFIGURED 로 떨어진다") + void reportsNotConfigured() { + environment(""); + when(repository.findTopByOrderByIdDesc()).thenReturn(Optional.empty()); + + assertThat(store.current()).isEmpty(); + assertThat(store.status().configured()).isFalse(); + assertThat(store.status().source()).isEqualTo(AdfitSessionCookieSource.NONE); + } + + @Test + @DisplayName("상태에는 쿠키 값을 담지 않는다. 콘솔 로그인 자격이라 읽히면 계정이 열린다") + void statusNeverCarriesCookieValue() { + environment(""); + when(repository.findTopByOrderByIdDesc()) + .thenReturn(Optional.of(new AdfitSessionCookie("KAKAO=secret"))); + + assertThat(store.status().toString()).doesNotContain("secret"); + } + + @Test + @DisplayName("name=value 형태가 아니면 저장하지 않는다. 조회가 조용히 실패하게 두지 않는다") + void rejectsNonCookieInput() { + assertThatThrownBy(() -> store.update("붙여넣기_실수")) + .isInstanceOf(IllegalArgumentException.class); + + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("환경변수가 비어 있어도 빈이 만들어진다. ADFIT_SESSION_COOKIE 미설정이 기동을 막지 않아야 한다") + void springCreatesStoreWithoutConfiguredCookie() { + try (var context = new org.springframework.context.annotation.AnnotationConfigApplicationContext()) { + context.registerBean(AdfitSessionCookieRepository.class, () -> repository); + context.register(AdfitSessionCookieStore.class); + context.refresh(); + when(repository.findTopByOrderByIdDesc()).thenReturn(Optional.empty()); + + assertThat(context.getBean(AdfitSessionCookieStore.class).current()).isEmpty(); + } + } + + @Test + @DisplayName("이미 저장된 행이 있으면 새로 만들지 않고 값만 바꾼다") + void updatesExistingRow() { + AdfitSessionCookie stored = new AdfitSessionCookie("OLD=1"); + when(repository.findTopByOrderByIdDesc()).thenReturn(Optional.of(stored)); + + store.update(" NEW=2 "); + + assertThat(stored.getValue()).isEqualTo("NEW=2"); + verify(repository).save(stored); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java b/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java new file mode 100644 index 00000000..803a39a0 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/adfit/AdminAdfitSecurityTest.java @@ -0,0 +1,58 @@ +package com.swyp.picke.domain.admin.adfit; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringJUnitConfig(AdminAdfitSecurityTest.Config.class) +class AdminAdfitSecurityTest { + @Configuration + @EnableMethodSecurity + static class Config { + @Bean AdfitReportService service() { return mock(AdfitReportService.class); } + @Bean AdminAdfitController controller(AdfitReportService service) { return new AdminAdfitController(service); } + } + @Autowired AdminAdfitController controller; + + @Test @WithMockUser(roles = "USER") + void regularUserCannotReadOrWriteIncome() { + var date = LocalDate.of(2026, 9, 1); + assertThatThrownBy(() -> controller.report(date, date)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.save(new AdfitDailyRequest(date, AdfitUnit.BANNER, + BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS))) + .isInstanceOf(AccessDeniedException.class); + // 세션 쿠키는 AdFit 콘솔 로그인 자격이다. 일반 사용자가 넣거나 상태를 엿볼 수 없어야 한다. + assertThatThrownBy(() -> controller.sessionCookieStatus()).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.updateSessionCookie(new AdfitSessionCookieRequest("KAKAO=x"))) + .isInstanceOf(AccessDeniedException.class); + } + + @Test @WithMockUser(roles = "ADMIN") + void adminCanReadAndWriteIncome() { + var date = LocalDate.of(2026, 9, 1); + assertThatCode(() -> controller.report(date, date)).doesNotThrowAnyException(); + assertThatCode(() -> controller.save(new AdfitDailyRequest(date, AdfitUnit.BANNER, + BigDecimal.ONE, BigDecimal.ONE, AdfitCostBasis.AD_OPERATIONS))).doesNotThrowAnyException(); + assertThatCode(() -> controller.sessionCookieStatus()).doesNotThrowAnyException(); + assertThatCode(() -> controller.updateSessionCookie(new AdfitSessionCookieRequest("KAKAO=x"))) + .doesNotThrowAnyException(); + } + + @Test void rejectsNegativeMoneyAndMissingBasis() { + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var errors = factory.getValidator().validate(new AdfitDailyRequest(LocalDate.now(), + AdfitUnit.BANNER, new BigDecimal("-1"), new BigDecimal("1.001"), null)); + assertThat(errors).extracting(error -> error.getPropertyPath().toString()) + .containsExactlyInAnyOrder("revenue", "cost", "costBasis"); + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsSecurityTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsSecurityTest.java new file mode 100644 index 00000000..67aeb32a --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/AdminAnalyticsSecurityTest.java @@ -0,0 +1,60 @@ +package com.swyp.picke.domain.admin.analytics; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +@SpringJUnitConfig(AdminAnalyticsSecurityTest.Config.class) +class AdminAnalyticsSecurityTest { + + @Configuration + @EnableMethodSecurity + static class Config { + @Bean MixpanelClient mixpanelClient() { return mock(MixpanelClient.class); } + @Bean SentryClient sentryClient() { return mock(SentryClient.class); } + @Bean AdminAnalyticsController controller(MixpanelClient mixpanel, SentryClient sentry) { + return new AdminAnalyticsController(mixpanel, sentry); + } + } + + @Autowired AdminAnalyticsController controller; + + private final LocalDate date = LocalDate.of(2026, 9, 1); + + @Test + @WithMockUser(roles = "USER") + @DisplayName("일반 사용자는 지표를 볼 수 없다") + void regularUserCannotReadMetrics() { + assertThatThrownBy(() -> controller.mixpanel(date, date, List.of("앱 실행"))) + .isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.sentry(date, date)).isInstanceOf(AccessDeniedException.class); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("관리자는 지표를 본다") + void adminCanReadMetrics() { + assertThatCode(() -> controller.mixpanel(date, date, List.of("앱 실행"))).doesNotThrowAnyException(); + assertThatCode(() -> controller.sentry(date, date)).doesNotThrowAnyException(); + } + + @Test + @WithMockUser(roles = "ADMIN") + @DisplayName("366일을 넘는 기간은 거절한다") + void rejectsTooWideRange() { + assertThatThrownBy(() -> controller.sentry(date, date.plusYears(2))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java new file mode 100644 index 00000000..2866c0b8 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/MixpanelClientTest.java @@ -0,0 +1,181 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class MixpanelClientTest { + + private final LocalDate from = LocalDate.of(2026, 9, 8); + private final LocalDate to = LocalDate.of(2026, 9, 10); + + private static class CapturingTransport implements AnalyticsHttpTransport { + private final AnalyticsHttpResponse response; + URI uri; + Map headers; + + CapturingTransport(AnalyticsHttpResponse response) { + this.response = response; + } + + @Override + public AnalyticsHttpResponse get(URI uri, Map headers) { + this.uri = uri; + this.headers = headers; + return response; + } + } + + /** 원본 export 는 NDJSON 이며 content-type 이 JSON 이 아닐 수 있다. 본문만 보고 판단한다. */ + private AnalyticsHttpResponse response(int status, String body) { + return new AnalyticsHttpResponse(status, Map.of("content-type", List.of("text/plain")), body); + } + + private MixpanelClient client(String secret, List defaultEvents, AnalyticsHttpTransport transport) { + return new MixpanelClient("https://data.mixpanel.com", secret, ZoneId.of("UTC"), + defaultEvents, transport, new ObjectMapper()); + } + + /** properties.time 은 프로젝트 타임존 기준 epoch 초다. 아래 값은 UTC 자정 직후를 가리킨다. */ + private String line(String event, String date, int hour) { + long epoch = LocalDate.parse(date).atStartOfDay(ZoneId.of("UTC")).plusHours(hour).toEpochSecond(); + return "{\"event\":\"" + event + "\",\"properties\":{\"time\":" + epoch + + ",\"distinct_id\":\"user-" + hour + "\"}}"; + } + + @Test + @DisplayName("API 비밀이 없으면 호출하지 않고 NOT_CONFIGURED 를 준다") + void returnsNotConfiguredWithoutSecret() { + var transport = new CapturingTransport(response(200, "")); + + var result = client("", List.of(), transport).fetchDailyCounts(null, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.NOT_CONFIGURED); + assertThat(transport.uri).isNull(); + } + + @Test + @DisplayName("원본 이벤트를 일별로 센다. 이벤트가 없던 날짜는 미집계가 아니라 0 이다") + void countsRawEventsPerDay() { + var transport = new CapturingTransport(response(200, String.join("\n", + line("screen_view", "2026-09-08", 1), + line("screen_view", "2026-09-08", 5), + line("screen_view", "2026-09-10", 3), + line("sign_up", "2026-09-10", 4)))); + + var result = client("secret", List.of(), transport) + .fetchDailyCounts(List.of("screen_view"), from, to); + + String expected = Base64.getEncoder().encodeToString("secret:".getBytes(StandardCharsets.UTF_8)); + assertThat(transport.headers).containsEntry("Authorization", "Basic " + expected); + assertThat(transport.uri.toString()) + .contains("data.mixpanel.com/api/2.0/export") + .contains("from_date=2026-09-08") + .contains("to_date=2026-09-10") + // 레거시 비밀 인증에 project_id 를 넣으면 400 이 된다. + .doesNotContain("project_id"); + assertThat(result.status()).isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(result.events()).hasSize(1); + assertThat(result.availableEvents()).containsExactly("screen_view", "sign_up"); + assertThat(result.totalEvents()).isEqualTo(3); + assertThat(result.uniqueUsers()).isEqualTo(3); + assertThat(result.summaryDate()).isEqualTo(to); + assertThat(result.activeUsers()).isEqualTo(2); + assertThat(result.signUps()).isEqualTo(1); + assertThat(result.signUpDays()) + .extracting(MixpanelEventReport.SignUpDay::date, MixpanelEventReport.SignUpDay::count) + .containsExactly(tuple(LocalDate.of(2026, 9, 8), 0L), + tuple(LocalDate.of(2026, 9, 9), 0L), + tuple(LocalDate.of(2026, 9, 10), 1L)); + assertThat(result.events().getFirst().total()).isEqualTo(3); + assertThat(result.events().getFirst().uniqueUsers()).isEqualTo(3); + assertThat(result.events().getFirst().firstSeen()).isNotNull(); + assertThat(result.events().getFirst().lastSeen()).isNotNull(); + assertThat(result.events().getFirst().days()) + .extracting(MixpanelEventReport.Day::date, MixpanelEventReport.Day::count) + .containsExactly(tuple(LocalDate.of(2026, 9, 10), 1L), + tuple(LocalDate.of(2026, 9, 9), 0L), + tuple(LocalDate.of(2026, 9, 8), 2L)); + assertThat(result.events().getFirst().days()) + .extracting(MixpanelEventReport.Day::uniqueUsers) + .containsExactly(1L, 0L, 2L); + } + + @Test + @DisplayName("이벤트를 지정하지 않으면 기간에 나타난 이벤트 전부를 발생 수 내림차순으로 센다") + void countsEveryEventWhenNoneRequested() { + var transport = new CapturingTransport(response(200, String.join("\n", + line("screen_view", "2026-09-08", 1), + line("screen_view", "2026-09-09", 2), + line("ui_action", "2026-09-09", 2), + line("sign_up", "2026-09-10", 2), + line("sign_up", "2026-09-10", 3), + line("sign_up", "2026-09-10", 4)))); + + var result = client("secret", List.of(), transport).fetchDailyCounts(null, from, to); + + assertThat(result.events()) + .extracting(MixpanelEventReport.EventSeries::event, MixpanelEventReport.EventSeries::total) + .containsExactly(tuple("sign_up", 3L), tuple("screen_view", 2L), tuple("ui_action", 1L)); + } + + @Test + @DisplayName("요청 이벤트가 기간에 없으면 0 으로 채운 시리즈를 준다. 이벤트를 통째로 빼지 않는다") + void keepsRequestedEventWithoutData() { + var transport = new CapturingTransport(response(200, line("screen_view", "2026-09-08", 1))); + + var result = client("secret", List.of(), transport) + .fetchDailyCounts(List.of("battle_step"), from, to); + + assertThat(result.events()).hasSize(1); + assertThat(result.events().getFirst().event()).isEqualTo("battle_step"); + assertThat(result.events().getFirst().total()).isZero(); + assertThat(result.events().getFirst().days()).hasSize(3) + .extracting(MixpanelEventReport.Day::count).containsOnly(0L); + } + + @Test + @DisplayName("타임존 차이로 경계를 넘어온 이벤트는 버린다") + void dropsEventsOutsideRequestedRange() { + var transport = new CapturingTransport(response(200, String.join("\n", + line("screen_view", "2026-09-10", 23), + line("screen_view", "2026-09-11", 8)))); + + var result = client("secret", List.of(), transport) + .fetchDailyCounts(List.of("screen_view"), from, to); + + assertThat(result.events().getFirst().total()).isEqualTo(1); + } + + @Test + @DisplayName("플랜 제한이나 자격 거절이면 UNAVAILABLE 이다. 0 으로 보여주지 않는다") + void reportsUnavailableOnRejectedRequest() { + var transport = new CapturingTransport(response(402, + "{\"error\":\"Your plan does not allow API calls.\"}")); + + var result = client("secret", List.of(), transport).fetchDailyCounts(null, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.events()).isEmpty(); + } + + @Test + @DisplayName("본문 모양이 다르면 UNAVAILABLE 이다") + void reportsUnavailableOnUnexpectedBody() { + var transport = new CapturingTransport(response(200, "{\"status\":\"ok\"}")); + + var result = client("secret", List.of(), transport).fetchDailyCounts(null, from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java b/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java new file mode 100644 index 00000000..2fba3377 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/analytics/SentryClientTest.java @@ -0,0 +1,381 @@ +package com.swyp.picke.domain.admin.analytics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +class SentryClientTest { + + private final LocalDate from = LocalDate.of(2026, 9, 8); + private final LocalDate to = LocalDate.of(2026, 9, 10); + + /** 프로젝트 슬러그별로 다른 응답을 준다. 슬러그에 맞는 응답이 없으면 마지막 등록 응답을 쓴다. */ + private static class CapturingTransport implements AnalyticsHttpTransport { + private final Map byProject = new java.util.LinkedHashMap<>(); + private AnalyticsHttpResponse statsResponse = new AnalyticsHttpResponse( + 200, + Map.of("content-type", List.of("application/json")), + "[[1788825600,25],[1788912000,40],[1788998400,60]]"); + private AnalyticsHttpResponse eventResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "[]"); + private AnalyticsHttpResponse analyticsEventCatalogResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "{\"data\":[]}"); + private AnalyticsHttpResponse analyticsEventTimeseriesResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), "{\"timeSeries\":[]}"); + private AnalyticsHttpResponse datasetResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), """ + {"timeSeries":[{"values":[ + {"timestamp":1788825600000,"value":1}, + {"timestamp":1788912000000,"value":2}, + {"timestamp":1788998400000,"value":3} + ]}]} + """); + private AnalyticsHttpResponse metricResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), + "[{\"name\":\"app.launch.count\",\"type\":\"counter\",\"unit\":null,\"count\":8}]"); + private AnalyticsHttpResponse sessionResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), """ + {"intervals":["2026-09-08T00:00:00Z","2026-09-09T00:00:00Z","2026-09-10T00:00:00Z"], + "groups":[{"by":{"session.status":"healthy"},"totals":{"sum(session)":9}, + "series":{"sum(session)":[2,3,4]}}]} + """); + private AnalyticsHttpResponse releaseResponse = new AnalyticsHttpResponse( + 200, Map.of("content-type", List.of("application/json")), + "[{\"version\":\"picke-ios@1.2.3+45\",\"status\":\"open\"}]"); + final List uris = new java.util.ArrayList<>(); + Map headers; + + CapturingTransport(AnalyticsHttpResponse response) { + byProject.put("*", response); + } + + CapturingTransport with(String project, AnalyticsHttpResponse response) { + byProject.put(project, response); + return this; + } + + CapturingTransport withStats(AnalyticsHttpResponse response) { + statsResponse = response; + return this; + } + + CapturingTransport withEvents(AnalyticsHttpResponse response) { + eventResponse = response; + return this; + } + + CapturingTransport withAnalyticsEvents( + AnalyticsHttpResponse catalogResponse, + AnalyticsHttpResponse timeseriesResponse) { + analyticsEventCatalogResponse = catalogResponse; + analyticsEventTimeseriesResponse = timeseriesResponse; + return this; + } + + @Override + public AnalyticsHttpResponse get(URI uri, Map headers) { + this.uris.add(uri); + this.headers = headers; + if (uri.getPath().endsWith("/stats/")) { + return statsResponse; + } + if (uri.getPath().contains("/organizations/") && uri.getPath().endsWith("/events/")) { + return analyticsEventCatalogResponse; + } + if (uri.getPath().endsWith("/events/")) { + return eventResponse; + } + if (uri.getPath().endsWith("/events-timeseries/")) { + if (uri.getQuery() != null && uri.getQuery().contains("topEvents=")) { + return analyticsEventTimeseriesResponse; + } + return datasetResponse; + } + if (uri.getPath().endsWith("/trace-items/metrics/")) { + return metricResponse; + } + if (uri.getPath().endsWith("/sessions/")) { + return sessionResponse; + } + if (uri.getPath().endsWith("/releases/")) { + return releaseResponse; + } + return byProject.entrySet().stream() + .filter(entry -> uri.getPath().contains(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(byProject.get("*")); + } + } + + private AnalyticsHttpResponse response(int status, String contentType, String body) { + return new AnalyticsHttpResponse(status, Map.of("content-type", List.of(contentType)), body); + } + + private SentryClient client(String token, AnalyticsHttpTransport transport) { + return new SentryClient("https://sentry.io", "picke", List.of("picke-ios", "picke-android"), + token, transport, new ObjectMapper()); + } + + @Test + @DisplayName("토큰이 없으면 호출하지 않고 NOT_CONFIGURED 를 준다") + void returnsNotConfiguredWithoutToken() { + var transport = new CapturingTransport(response(200, "application/json", "[]")); + + var result = client("", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.NOT_CONFIGURED); + assertThat(result.projects()).isEmpty(); + assertThat(transport.uris).isEmpty(); + } + + @Test + @DisplayName("iOS·Android 를 프로젝트별로 나눠 읽는다. 절대 기간을 쓰려면 statsPeriod 를 비워 보낸다") + void fetchesEveryProjectWithAbsoluteRange() { + var transport = new CapturingTransport(response(200, "application/json", """ + [ + {"id":"1","title":"Crash A","culprit":"VoteView","level":"error","count":"120", + "userCount":31,"firstSeen":"2026-09-08T01:00:00Z","lastSeen":"2026-09-10T04:00:00Z", + "permalink":"https://sentry.io/organizations/picke/issues/1/"}, + {"id":"2","title":"Crash B","culprit":null,"level":"fatal","count":5, + "userCount":null,"firstSeen":"2026-09-09T01:00:00Z","lastSeen":"2026-09-09T02:00:00Z", + "permalink":null} + ] + """)).withEvents(response(200, "application/json", """ + [ + {"eventID":"event-a","id":"event-a","groupID":"1","projectID":"11", + "title":"Crash A","message":"Fatal signal","platform":"cocoa","event.type":"error", + "location":"VoteView.swift","culprit":"VoteView","crashFile":"app.dSYM", + "dateCreated":"2026-09-10T04:00:00Z", + "tags":[{"key":"environment","value":"production"},{"key":"release","value":"1.2.3"}], + "metadata":{"filename":"VoteView.swift","function":"submitVote"}, + "user":{"email":"should-not-be-copied@example.com"}} + ] + """)); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(transport.headers).containsEntry("Authorization", "Bearer token"); + assertThat(transport.uris).hasSize(26); + assertThat(transport.uris.getFirst().toString()) + .contains("/api/0/projects/picke/picke-ios/issues/") + .contains("query=is:unresolved") + .contains("sort=freq") + .contains("statsPeriod=") + .contains("start=2026-09-08T00:00") + .contains("end=2026-09-10T23:59:59"); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/projects/picke/picke-android/issues/")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/projects/picke/picke-ios/stats/") + && uri.toString().contains("resolution=1d") + && uri.toString().contains("stat=received")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/projects/picke/picke-ios/events/") + && uri.toString().contains("start=2026-09-08T00:00") + && uri.toString().contains("end=2026-09-10T23:59:59") + && uri.toString().contains("full=true")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/organizations/picke/events-timeseries/") + && uri.toString().contains("project=picke-ios") + && uri.toString().contains("dataset=logs") + && uri.toString().contains("yAxis=count()")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/organizations/picke/events-timeseries/") + && uri.toString().contains("project=picke-ios") + && uri.toString().contains("dataset=tracemetrics") + && uri.toString().contains("yAxis=sum(value)")); + assertThat(transport.uris).anyMatch(uri -> uri.toString() + .contains("/api/0/organizations/picke/events-timeseries/") + && uri.toString().contains("project=picke-ios") + && uri.toString().contains("dataset=tracemetrics") + && uri.toString().contains("yAxis=sum(value)") + && uri.toString().contains("event:sign_up")); + assertThat(result.status()).isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(result.fetchedAt()).isNotNull(); + assertThat(result.projects()).extracting(SentryIssueReport.ProjectIssues::project, + SentryIssueReport.ProjectIssues::totalEvents) + .containsExactly(tuple("picke-ios", 125L), tuple("picke-android", 125L)); + // 두 프로젝트 합계다. 프로젝트별 합계와 구분한다. + assertThat(result.totalEvents()).isEqualTo(250); + assertThat(result.projects().getFirst().days()) + .extracting(SentryIssueReport.Day::date, SentryIssueReport.Day::events) + .containsExactly(tuple(LocalDate.of(2026, 9, 8), 25L), + tuple(LocalDate.of(2026, 9, 9), 40L), + tuple(LocalDate.of(2026, 9, 10), 60L)); + assertThat(result.projects().getFirst().unresolvedEvents()).isEqualTo(125); + var issues = result.projects().getFirst().issues(); + assertThat(issues).extracting(SentryIssueReport.Issue::title, SentryIssueReport.Issue::events) + .containsExactly(tuple("Crash A", 120L), tuple("Crash B", 5L)); + assertThat(issues.getFirst().users()).isEqualTo(31); + assertThat(issues.getLast().users()).isNull(); + var event = result.projects().getFirst().recentEvents().getFirst(); + assertThat(event.eventId()).isEqualTo("event-a"); + assertThat(event.groupId()).isEqualTo("1"); + assertThat(event.platform()).isEqualTo("cocoa"); + assertThat(event.type()).isEqualTo("error"); + assertThat(event.tags()).extracting(SentryIssueReport.Tag::key, SentryIssueReport.Tag::value) + .containsExactly(tuple("environment", "production"), tuple("release", "1.2.3")); + assertThat(event.metadata()).containsEntry("filename", "VoteView.swift") + .containsEntry("function", "submitVote"); + assertThat(event.details()).containsKey("user").containsKey("tags").containsKey("metadata"); + assertThat(result.projects().getFirst().datasets()) + .extracting(SentryIssueReport.DatasetSeries::dataset, + SentryIssueReport.DatasetSeries::status, + SentryIssueReport.DatasetSeries::total) + .containsExactly( + tuple("errors", AnalyticsStatus.CONNECTED, 6L), + tuple("logs", AnalyticsStatus.CONNECTED, 6L), + tuple("spans", AnalyticsStatus.CONNECTED, 6L), + tuple("profile_functions", AnalyticsStatus.CONNECTED, 6L), + tuple("tracemetrics", AnalyticsStatus.CONNECTED, 6L), + tuple("sign_up", AnalyticsStatus.CONNECTED, 6L)); + assertThat(result.projects().getFirst().analyticsEvents().status()) + .isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(result.projects().getFirst().analyticsEvents().events()).isEmpty(); + assertThat(result.projects().getFirst().metricCatalog().entries().getFirst()) + .containsEntry("name", "app.launch.count") + .containsEntry("count", 8); + assertThat(result.projects().getFirst().sessionHealth().series().getFirst().status()) + .isEqualTo("healthy"); + assertThat(result.projects().getFirst().sessionHealth().series().getFirst().total()).isEqualTo(9); + assertThat(result.projects().getFirst().releases().entries().getFirst()) + .containsEntry("version", "picke-ios@1.2.3+45"); + } + + + @Test + @DisplayName("analytics_event 태그로 수집한 모든 액션을 발생 수와 사용자 일별 추이로 묶는다") + void groupsEveryAnalyticsEventLikeMixpanel() { + var transport = new CapturingTransport(response(200, "application/json", "[]")) + .withAnalyticsEvents( + response(200, "application/json", """ + {"data":[ + {"tag[analytics_event,string]":"ui_action","count()":7, + "count_unique(user)":3,"min(timestamp)":"2026-09-08T01:00:00Z", + "max(timestamp)":"2026-09-10T02:00:00Z"}, + {"tag[analytics_event,string]":"sign_up","count()":"2", + "count_unique(user)":2,"min(timestamp)":"2026-09-09T03:00:00Z", + "max(timestamp)":"2026-09-10T04:00:00Z"} + ]} + """), + response(200, "application/json", """ + {"timeSeries":[ + {"yAxis":"count()","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}], + "values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":2},{"timestamp":1788998400000,"value":4}]}, + {"yAxis":"count_unique(user)","groupBy":[{"key":"tag[analytics_event,string]","value":"ui_action"}], + "values":[{"timestamp":1788825600000,"value":1},{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":2}]}, + {"yAxis":"count()","groupBy":[{"key":"analytics_event","value":"sign_up"}], + "values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]}, + {"yAxis":"count_unique(user)","groupBy":[{"key":"analytics_event","value":"sign_up"}], + "values":[{"timestamp":1788912000000,"value":1},{"timestamp":1788998400000,"value":1}]} + ]} + """)); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events/") + && uri.getPath().contains("/organizations/") + && uri.getQuery().contains("field=tag[analytics_event,string]") + && uri.getQuery().contains("query=has:analytics_event")); + assertThat(transport.uris).anyMatch(uri -> uri.getPath().endsWith("/events-timeseries/") + && uri.getQuery().contains("groupBy=tag[analytics_event,string]") + && uri.getQuery().contains("topEvents=2") + && uri.getQuery().contains("yAxis=count_unique(user)")); + var analytics = result.projects().getFirst().analyticsEvents(); + assertThat(analytics.status()).isEqualTo(AnalyticsStatus.CONNECTED); + assertThat(analytics.totalEvents()).isEqualTo(9); + assertThat(analytics.events()) + .extracting(SentryIssueReport.AnalyticsEventSeries::event, + SentryIssueReport.AnalyticsEventSeries::total, + SentryIssueReport.AnalyticsEventSeries::uniqueUsers) + .containsExactly(tuple("ui_action", 7L, 3L), tuple("sign_up", 2L, 2L)); + assertThat(analytics.events().getLast().days()) + .extracting(SentryIssueReport.AnalyticsEventDay::date, + SentryIssueReport.AnalyticsEventDay::count, + SentryIssueReport.AnalyticsEventDay::uniqueUsers) + .containsExactly( + tuple(LocalDate.of(2026, 9, 8), 0L, 0L), + tuple(LocalDate.of(2026, 9, 9), 1L, 1L), + tuple(LocalDate.of(2026, 9, 10), 1L, 1L)); + } + + @Test + @DisplayName("응답 순서를 믿지 않고 이벤트 수 내림차순으로 다시 정렬한다. sort=freq 가 절대 기간에서 순서를 보장하지 않는다") + void sortsIssuesByEventsDescending() { + var transport = new CapturingTransport(response(200, "application/json", """ + [ + {"id":"1","title":"적게","count":"38"}, + {"id":"2","title":"많이","count":"101"}, + {"id":"3","title":"모름","count":null}, + {"id":"4","title":"중간","count":"50"} + ] + """)); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.projects().getFirst().issues()).extracting(SentryIssueReport.Issue::title) + .containsExactly("많이", "중간", "적게", "모름"); + } + + @Test + @DisplayName("한 프로젝트가 막혀도 다른 프로젝트는 살린다. 전체 합계는 비운다") + void keepsHealthyProjectWhenOtherFails() { + var transport = new CapturingTransport(response(200, "application/json", + "[{\"id\":\"1\",\"title\":\"Android Crash\",\"count\":\"7\"}]")) + .with("picke-ios", response(403, "application/json", "{\"detail\":\"forbidden\"}")); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.totalEvents()).isNull(); + assertThat(result.projects()).extracting(SentryIssueReport.ProjectIssues::project, + SentryIssueReport.ProjectIssues::status) + .containsExactly(tuple("picke-ios", AnalyticsStatus.UNAVAILABLE), + tuple("picke-android", AnalyticsStatus.CONNECTED)); + assertThat(result.projects().getLast().issues()).hasSize(1); + } + + @Test + @DisplayName("토큰이 거절되면 UNAVAILABLE 이다. 0건으로 보여주지 않는다") + void reportsUnavailableOnRejectedToken() { + var transport = new CapturingTransport(response(401, "application/json", "{\"detail\":\"invalid\"}")); + + var result = client("expired", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.totalEvents()).isNull(); + assertThat(result.projects()).allMatch(project -> project.issues().isEmpty()); + } + + @Test + @DisplayName("응답 모양이 다르면 UNAVAILABLE 이다") + void reportsUnavailableOnUnexpectedSchema() { + var transport = new CapturingTransport(response(200, "application/json", "{\"issues\":[]}")); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.projects()).extracting(SentryIssueReport.ProjectIssues::status) + .containsOnly(AnalyticsStatus.UNAVAILABLE); + } + + @Test + @DisplayName("일별 통계 응답이 잘못되면 0건으로 꾸미지 않고 UNAVAILABLE 이다") + void reportsUnavailableOnUnexpectedStatsSchema() { + var transport = new CapturingTransport(response(200, "application/json", "[]")) + .withStats(response(200, "application/json", "{\"points\":[]}")); + + var result = client("token", transport).fetchUnresolvedIssues(from, to); + + assertThat(result.status()).isEqualTo(AnalyticsStatus.UNAVAILABLE); + assertThat(result.projects()).allMatch(project -> project.days().isEmpty()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java index 6ef1d455..0998a24e 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java @@ -29,9 +29,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; -import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.time.LocalDate; import java.util.LinkedHashMap; @@ -40,9 +38,6 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -99,7 +94,7 @@ void createBattle_persistsAllMappedFields() throws Exception { LocalDate targetDate = LocalDate.now(); Tag category = createTag("battle-category", TagType.CATEGORY); - Tag philosopher = createTag("battle-philosopher", TagType.PHILOSOPHER); + Tag philosopher = createTag("소크라테스", TagType.PHILOSOPHER); Tag value = createTag("battle-value", TagType.VALUE); Map payload = Map.of( @@ -349,12 +344,16 @@ void pending_local_images_are_promoted_to_s3_on_publish() throws Exception { .content(objectMapper.writeValueAsString(publishPayload))) .andExpect(status().isOk()); + // 실제 S3 업로드/로컬 draft 삭제는 DB 트랜잭션 커밋 이후로 미뤄진다 + // (트랜잭션 중간에 실패해도 이미 올라간 파일이 orphan되는 걸 막기 위함 — [[s3_railway_bucket_migration]] 참고). + // 이 테스트는 클래스 레벨 @Transactional로 실제 커밋 없이 끝에 롤백되므로, + // "커밋 이후에만 실제로 승격됨"을 여기서 직접 증명하지는 않는다. + // 그 배선(afterCommit 등록 여부)은 LocalDraftFileStorageServiceTest/단위테스트에서 검증한다. + // 여기서는 요청 처리 중 동기적으로 결정되는 최종 저장 키(목적지 경로)만 확인한다. Battle publishedBattle = battleRepository.findById(battleId).orElseThrow(); assertThat(publishedBattle.getThumbnailUrl()).startsWith("images/battles/"); List publishedOptions = battleOptionRepository.findByBattle(publishedBattle); assertThat(publishedOptions).allMatch(option -> option.getImageUrl().startsWith("images/philosophers/")); - - verify(s3Client, atLeastOnce()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } private String uploadLocalDraftKey(String adminToken, String fileName, String content) throws Exception { @@ -395,6 +394,9 @@ private User createAdminUser() { } private Tag createTag(String prefix, TagType type) { + if (type == TagType.PHILOSOPHER) { + return tagRepository.save(Tag.builder().name(prefix).type(type).build()); + } String normalizedPrefix = prefix.length() > 10 ? prefix.substring(0, 10) : prefix; return tagRepository.save( Tag.builder() diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java index d06b4681..17e314a3 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNoticeIntegrationTest.java @@ -26,9 +26,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItem; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -98,21 +96,14 @@ void admin_can_create_and_list_notices() throws Exception { } @Test - @DisplayName("admin notice page form flow persists notice and user can fetch it") - void admin_notice_page_form_flow_persists_notice_and_user_can_fetch_it() throws Exception { + @DisplayName("admin notice creation flow persists notice and user can fetch it") + void admin_notice_creation_flow_persists_notice_and_user_can_fetch_it() throws Exception { String adminToken = createAdminToken(); String userToken = createUserToken(); String marker = UUID.randomUUID().toString().substring(0, 8); String title = "ui-notice-" + marker; String body = "ui-body-" + marker; - mockMvc.perform(get("/api/v1/admin/picke/notice")) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("id=\"notice-form\""))) - .andExpect(content().string(containsString("id=\"notice-title\""))) - .andExpect(content().string(containsString("id=\"notice-body\""))) - .andExpect(content().string(containsString("/js/admin/notice/notice.js"))); - Map payload = Map.of( "category", "NOTICE", "title", title, diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java new file mode 100644 index 00000000..cf7d7d77 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminNotificationTestPushIntegrationTest.java @@ -0,0 +1,119 @@ +package com.swyp.picke.domain.admin.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminNotificationTestPushIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JwtProvider jwtProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserDeviceRepository userDeviceRepository; + + @MockitoBean + private S3Client s3Client; + + @MockitoBean + private S3PresignedUrlService s3PresignedUrlService; + + @MockitoBean + private FcmPushService fcmPushService; + + @MockitoBean + private ApnsPushService apnsPushService; + + @Test + @DisplayName("admin can send a test push to a specific user's registered device") + void admin_can_send_test_push_to_user_device() throws Exception { + String adminToken = createAdminToken(); + + User targetUser = userRepository.save( + User.builder() + .userTag("target-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("target") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build() + ); + + UserDevice device = userDeviceRepository.save( + UserDevice.builder() + .user(targetUser) + .fcmToken("test-token-" + UUID.randomUUID()) + .platform(DevicePlatform.ANDROID) + .build() + ); + + Map payload = Map.of( + "userId", targetUser.getId(), + "title", "테스트 알림", + "body", "테스트 발송 본문" + ); + + mockMvc.perform(post("/api/v1/admin/notices/test") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isOk()); + + ArgumentCaptor deviceCaptor = ArgumentCaptor.forClass(UserDevice.class); + verify(fcmPushService).send(deviceCaptor.capture(), eq("테스트 알림"), eq("테스트 발송 본문"), any()); + assertThat(deviceCaptor.getValue().getId()).isEqualTo(device.getId()); + } + + private String createAdminToken() { + User admin = userRepository.save( + User.builder() + .userTag("adm-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("adm") + .role(UserRole.ADMIN) + .status(UserStatus.ACTIVE) + .build() + ); + return jwtProvider.createAccessToken(admin.getId(), UserRole.ADMIN.name()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java new file mode 100644 index 00000000..18ff0342 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminAdServiceTest.java @@ -0,0 +1,198 @@ +package com.swyp.picke.domain.admin.service; + +import com.swyp.picke.domain.ad.entity.AdCreative; +import com.swyp.picke.domain.ad.enums.AdSource; +import com.swyp.picke.domain.ad.enums.AdNetwork; +import com.swyp.picke.domain.ad.enums.AdSlotCode; +import com.swyp.picke.domain.ad.enums.AdStatus; +import com.swyp.picke.domain.ad.enums.AdTargetOs; +import com.swyp.picke.domain.ad.service.AdCreativeCodeGenerator; +import com.swyp.picke.domain.ad.repository.AdClickLogRepository; +import com.swyp.picke.domain.ad.repository.AdCreativeRepository; +import com.swyp.picke.domain.ad.repository.AdImpressionDailyRepository; +import com.swyp.picke.domain.admin.dto.ad.request.AdCreativeRequest; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import java.util.Optional; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminAdServiceTest { + + private static final String OUR_PARTNERS_ID = "AF6830373"; + + @Mock + private AdCreativeRepository adCreativeRepository; + @Mock + private AdClickLogRepository adClickLogRepository; + @Mock + private AdImpressionDailyRepository adImpressionDailyRepository; + @Mock + private AdCreativeCodeGenerator adCreativeCodeGenerator; + + @InjectMocks + private AdminAdService adminAdService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(adminAdService, "coupangPartnersId", OUR_PARTNERS_ID); + when(adCreativeCodeGenerator.generate()).thenReturn("abc23456"); + when(adCreativeRepository.save(any(AdCreative.class))).thenAnswer(call -> call.getArgument(0)); + } + + private AdCreativeRequest request(AdNetwork network, String landingUrl) { + return new AdCreativeRequest( + network, + AdSlotCode.HOME_FEED, + "무선 이어폰", + null, + "https://img.example.com/1.jpg", + "구매하러 가기", + landingUrl, + AdStatus.ACTIVE, + AdTargetOs.ALL, + 1, + null, + null + ); + } + + @Test + @DisplayName("형식이 깨진 제휴 링크는 등록 시점에 막는다. 클릭 시점 500을 앞당겨 잡는다") + void create_rejectsMalformedLandingUrl() { + // 인코딩되지 않은 공백이 들어간 URL. 클릭 시점 AffiliateLinks.merge 가 build(true) 로 파싱하다 터진다. + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/a b c"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AD_INVALID_LANDING_URL); + + verify(adCreativeRepository, never()).save(any(AdCreative.class)); + } + + @Test + @DisplayName("스킴이나 호스트가 없는 링크도 막는다") + void create_rejectsLandingUrlWithoutSchemeOrHost() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "/c/relative-only"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AD_INVALID_LANDING_URL); + } + + @Test + @DisplayName("애드픽 단축 링크처럼 파라미터 없는 정상 URL은 통과한다") + void create_acceptsShortAffiliateLink() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/884a6d6"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("남의 파트너스 아이디가 박힌 쿠팡 링크는 등록을 막는다") + void create_rejectsForeignPartnerLink() { + AdCreativeRequest request = request(AdNetwork.COUPANG, + "https://link.coupang.com/re/AFF?lptag=AF9999999&pageKey=1"); + + assertThatThrownBy(() -> adminAdService.create(request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.AD_COUPANG_PARTNER_MISMATCH); + + verify(adCreativeRepository, never()).save(any()); + } + + @Test + @DisplayName("우리 파트너스 아이디면 통과한다") + void create_allowsOwnPartnerLink() { + AdCreativeRequest request = request(AdNetwork.COUPANG, + "https://link.coupang.com/re/AFF?lptag=" + OUR_PARTNERS_ID + "&pageKey=1"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("lptag가 드러나지 않는 단축 링크는 막지 않는다") + void create_allowsShortLinkWithoutLptag() { + AdCreativeRequest request = request(AdNetwork.COUPANG, "https://link.coupang.com/a/abcdef"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("애드픽 소재는 쿠팡 아이디 검증 대상이 아니다") + void create_skipsValidationForOtherNetworks() { + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://adpick.co.kr/?lptag=AF9999999"); + + assertThatCode(() -> adminAdService.create(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("동기화 소재는 수정·삭제를 막는다") + void update_rejectsManagedCreative() { + AdCreative managed = AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("리워디 월렛") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl("https://deg.kr/39e859f") + .status(AdStatus.ACTIVE) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId("16b04") + .targetOs(AdTargetOs.ANDROID) + .build(); + when(adCreativeRepository.findById(1L)).thenReturn(Optional.of(managed)); + + AdCreativeRequest request = request(AdNetwork.ADPICK, "https://deg.kr/39e859f"); + + assertThatThrownBy(() -> adminAdService.update(1L, request)) + .isInstanceOf(CustomException.class) + .extracting(e -> ((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.AD_CREATIVE_MANAGED); + assertThatThrownBy(() -> adminAdService.delete(1L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("동기화 소재도 게재 상태는 바꿀 수 있다") + void changeStatus_allowedForManagedCreative() { + AdCreative managed = AdCreative.builder() + .code("syn00001") + .network(AdNetwork.ADPICK) + .slot(AdSlotCode.BATTLE_RESULT_BOTTOM) + .title("리워디 월렛") + .imageUrl("https://img.example.com/1.jpg") + .ctaText("설치하고 받기") + .landingUrl("https://deg.kr/39e859f") + .status(AdStatus.ACTIVE) + .weight(1) + .source(AdSource.ADPICK_API) + .externalId("16b04") + .targetOs(AdTargetOs.ANDROID) + .build(); + when(adCreativeRepository.findById(1L)).thenReturn(Optional.of(managed)); + + assertThat(adminAdService.changeStatus(1L, AdStatus.PAUSED).status()).isEqualTo(AdStatus.PAUSED); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java new file mode 100644 index 00000000..2be9448a --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminDashboardServiceTest.java @@ -0,0 +1,326 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardAttendanceStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardBattleStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardCreditStatsResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardDauMauResponse; +import com.swyp.picke.domain.admin.dto.dashboard.response.AdminDashboardSummaryResponse; +import com.swyp.picke.domain.attendance.repository.AttendanceRecordRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.CreditHistoryRepository; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.projection.CreditTypeStats; +import com.swyp.picke.domain.user.repository.projection.DailyUserCount; +import com.swyp.picke.global.common.exception.CustomException; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminDashboardServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @Mock + private BattleRepository battleRepository; + + @Mock + private AttendanceRecordRepository attendanceRecordRepository; + + @Mock + private CreditHistoryRepository creditHistoryRepository; + + @InjectMocks + private AdminDashboardService adminDashboardService; + + @Test + @DisplayName("오늘 요약 카드를 조회한다") + void getSummary_returnsTodaySummary() { + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(12L); + when(userDailyActivityRepository.countByActivityDateAndLoggedInTrue(eq(LocalDate.now()))).thenReturn(340L); + when(userDailyActivityRepository.countByActivityDateAndActiveTrue(eq(LocalDate.now()))).thenReturn(500L); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(58000L); + + AdminDashboardSummaryResponse response = adminDashboardService.getSummary(); + + assertThat(response.newUserCount()).isEqualTo(12L); + assertThat(response.loginUserCount()).isEqualTo(340L); + assertThat(response.activeUserCount()).isEqualTo(500L); + assertThat(response.totalUserCount()).isEqualTo(58000L); + } + + @Test + @DisplayName("granularity=day면 일자별 DAU 카운트를 조회한다") + void getDauMauTrend_day_returnsDailyActiveCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 10L), + dailyUserCount(LocalDate.of(2026, 8, 2), 15L) + ); + when(userDailyActivityRepository.findDailyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).date()).isEqualTo(LocalDate.of(2026, 8, 1)); + assertThat(response.items().get(0).count()).isEqualTo(10L); + assertThat(response.items().get(1).count()).isEqualTo(15L); + } + + @Test + @DisplayName("granularity=month면 롤링 30일 MAU 카운트를 조회한다") + void getDauMauTrend_month_returnsRollingMonthlyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 1); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 8, 1), 1200L)); + when(userDailyActivityRepository.findRollingMonthlyActiveUserCounts(from, to)).thenReturn(rows); + + AdminDashboardDauMauResponse response = adminDashboardService.getDauMauTrend(from, to, "month"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(1200L); + } + + @Test + @DisplayName("from이 to보다 늦으면 예외를 던진다") + void getDauMauTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getDauMauTrend(from, to, "day")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("granularity=day면 일자별 신규 가입자 수를 조회한다") + void getNewUsersTrend_day_returnsDailyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 3L), + dailyUserCount(LocalDate.of(2026, 8, 2), 5L) + ); + when(userRepository.findDailyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(8L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "day"); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(1).count()).isEqualTo(5L); + assertThat(response.totalCount()).isEqualTo(8L); + } + + @Test + @DisplayName("granularity=week면 주별 신규 가입자 수를 조회한다") + void getNewUsersTrend_week_returnsWeeklyCounts() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 14); + List rows = List.of(dailyUserCount(LocalDate.of(2026, 7, 27), 20L)); + when(userRepository.findWeeklyNewUserCounts(from, to)).thenReturn(rows); + when(userRepository.countByCreatedAtBetween(any(), any())).thenReturn(18L); + + var response = adminDashboardService.getNewUsersTrend(from, to, "week"); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).count()).isEqualTo(20L); + assertThat(response.totalCount()).isEqualTo(18L); + } + + @Test + @DisplayName("신규 가입자 추이 조회 시 from이 to보다 늦으면 예외를 던진다") + void getNewUsersTrend_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getNewUsersTrend(from, to, "day")) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("배틀당 평균 참여율을 조회한다") + void getBattleStats_returnsAverageRates() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 7); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + BattleParticipationStats stats = battleParticipationStats(5L, 0.8, 0.6, 0.4, 0.1); + when(battleRepository.findParticipationStats(from, to, 1000L)).thenReturn(stats); + + AdminDashboardBattleStatsResponse response = adminDashboardService.getBattleStats(from, to); + + assertThat(response.battleCount()).isEqualTo(5L); + assertThat(response.avgPreVoteRate()).isEqualTo(0.8); + assertThat(response.avgPostVoteRate()).isEqualTo(0.6); + assertThat(response.avgPerspectiveWriteRate()).isEqualTo(0.4); + assertThat(response.avgCommentWriteRate()).isEqualTo(0.1); + } + + @Test + @DisplayName("기간 내 발행된 배틀이 없으면 참여율은 0으로 반환한다") + void getBattleStats_returnsZero_whenNoBattlesPublished() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 7); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + BattleParticipationStats stats = battleParticipationStats(0L, null, null, null, null); + when(battleRepository.findParticipationStats(from, to, 1000L)).thenReturn(stats); + + AdminDashboardBattleStatsResponse response = adminDashboardService.getBattleStats(from, to); + + assertThat(response.battleCount()).isEqualTo(0L); + assertThat(response.avgPreVoteRate()).isEqualTo(0.0); + assertThat(response.avgCommentWriteRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("배틀 참여율 조회 시 from이 to보다 늦으면 예외를 던진다") + void getBattleStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getBattleStats(from, to)) + .isInstanceOf(CustomException.class); + } + + private BattleParticipationStats battleParticipationStats( + long battleCount, Double preVote, Double postVote, Double perspective, Double comment) { + return new BattleParticipationStats() { + @Override + public long getBattleCount() { + return battleCount; + } + + @Override + public Double getAvgPreVoteRate() { + return preVote; + } + + @Override + public Double getAvgPostVoteRate() { + return postVote; + } + + @Override + public Double getAvgPerspectiveRate() { + return perspective; + } + + @Override + public Double getAvgCommentRate() { + return comment; + } + }; + } + + @Test + @DisplayName("출석 체크율과 개근 보너스 달성 건수를 조회한다") + void getAttendanceStats_returnsRateAndStreakCount() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 2); + List rows = List.of( + dailyUserCount(LocalDate.of(2026, 8, 1), 100L), + dailyUserCount(LocalDate.of(2026, 8, 2), 200L) + ); + when(attendanceRecordRepository.findDailyAttendanceCounts(from, to)).thenReturn(rows); + when(userRepository.countByStatus(UserStatus.ACTIVE)).thenReturn(1000L); + when(creditHistoryRepository.countByCreditTypeAndCreatedAtBetween( + eq(CreditType.ATTENDANCE_STREAK), any(), any())).thenReturn(42L); + + AdminDashboardAttendanceStatsResponse response = adminDashboardService.getAttendanceStats(from, to); + + assertThat(response.items()).hasSize(2); + assertThat(response.totalCount()).isEqualTo(300L); + assertThat(response.avgAttendanceRate()).isCloseTo(0.15, org.assertj.core.data.Offset.offset(0.0001)); // (0.1 + 0.2) / 2 + assertThat(response.streakAchievedCount()).isEqualTo(42L); + } + + @Test + @DisplayName("출석 체크율 조회 시 from이 to보다 늦으면 예외를 던진다") + void getAttendanceStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getAttendanceStats(from, to)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("크레딧 타입별 지급/차감 현황을 조회한다") + void getCreditStats_returnsTypeBreakdownAndTotals() { + LocalDate from = LocalDate.of(2026, 8, 1); + LocalDate to = LocalDate.of(2026, 8, 7); + List rows = List.of( + creditTypeStats(CreditType.TODAY_CREDIT, 100L, 500L), + creditTypeStats(CreditType.MAJORITY_WIN, 20L, 100L), + creditTypeStats(CreditType.BATTLE_ENTRY, 30L, -150L) + ); + when(creditHistoryRepository.findStatsByCreatedAtBetween(any(), any())).thenReturn(rows); + + AdminDashboardCreditStatsResponse response = adminDashboardService.getCreditStats(from, to); + + assertThat(response.byType()).hasSize(3); + assertThat(response.totalGranted()).isEqualTo(600L); // 500 + 100 + assertThat(response.totalDeducted()).isEqualTo(150L); // |-150| + } + + @Test + @DisplayName("크레딧 지급 현황 조회 시 from이 to보다 늦으면 예외를 던진다") + void getCreditStats_throws_whenFromIsAfterTo() { + LocalDate from = LocalDate.of(2026, 8, 10); + LocalDate to = LocalDate.of(2026, 8, 1); + + assertThatThrownBy(() -> adminDashboardService.getCreditStats(from, to)) + .isInstanceOf(CustomException.class); + } + + private CreditTypeStats creditTypeStats(CreditType creditType, long count, long totalAmount) { + return new CreditTypeStats() { + @Override + public CreditType getCreditType() { + return creditType; + } + + @Override + public long getCount() { + return count; + } + + @Override + public long getTotalAmount() { + return totalAmount; + } + }; + } + + private DailyUserCount dailyUserCount(LocalDate date, long count) { + return new DailyUserCount() { + @Override + public LocalDate getActivityDate() { + return date; + } + + @Override + public long getCount() { + return count; + } + }; + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java new file mode 100644 index 00000000..9b73e0ca --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java @@ -0,0 +1,191 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleTestRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; +import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.global.common.exception.CustomException; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationScheduleServiceTest { + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + @Mock + private NotificationDispatchService notificationDispatchService; + + @InjectMocks + private AdminNotificationScheduleService adminNotificationScheduleService; + + @Test + @DisplayName("예약 알림을 생성한다") + void create_savesSchedule() { + AdminNotificationScheduleRequest request = + new AdminNotificationScheduleRequest("오늘의 질문", "지금 확인해보세요", LocalTime.of(19, 0), true); + when(notificationScheduleRepository.save(any(NotificationSchedule.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AdminNotificationScheduleResponse response = adminNotificationScheduleService.create(request); + + assertThat(response.title()).isEqualTo("오늘의 질문"); + assertThat(response.subtitle()).isEqualTo("지금 확인해보세요"); + assertThat(response.sendTime()).isEqualTo(LocalTime.of(19, 0)); + assertThat(response.enabled()).isTrue(); + } + + @Test + @DisplayName("예약 알림 목록을 조회한다") + void getSchedules_returnsAllSchedules() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findAll()).thenReturn(List.of(schedule)); + + AdminNotificationScheduleListResponse response = adminNotificationScheduleService.getSchedules(); + + assertThat(response.schedules()).hasSize(1); + assertThat(response.schedules().getFirst().title()).isEqualTo("오늘의 질문"); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 조회하면 예외를 던진다") + void getSchedule_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.getSchedule(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("예약 알림을 수정한다") + void update_updatesExistingSchedule() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + AdminNotificationScheduleRequest request = + new AdminNotificationScheduleRequest("수정된 제목", "수정된 소제목", LocalTime.of(20, 30), false); + + AdminNotificationScheduleResponse response = adminNotificationScheduleService.update(1L, request); + + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.subtitle()).isEqualTo("수정된 소제목"); + assertThat(response.sendTime()).isEqualTo(LocalTime.of(20, 30)); + assertThat(response.enabled()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 수정하면 예외를 던진다") + void update_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + AdminNotificationScheduleRequest request = + new AdminNotificationScheduleRequest("제목", "소제목", LocalTime.of(19, 0), true); + + assertThatThrownBy(() -> adminNotificationScheduleService.update(999L, request)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("예약 알림의 On/Off 상태를 전환한다") + void toggle_updatesEnabledState() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + AdminNotificationScheduleResponse response = + adminNotificationScheduleService.toggle(1L, new AdminNotificationScheduleToggleRequest(false)); + + assertThat(response.enabled()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 전환하면 예외를 던진다") + void toggle_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.toggle(999L, new AdminNotificationScheduleToggleRequest(true))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("예약 알림 저장 내용으로 특정 유저에게 테스트 발송한다") + void sendTest_dispatchesSavedScheduleContent() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + adminNotificationScheduleService.sendTest(1L, new AdminNotificationScheduleTestRequest(123L)); + + verify(notificationDispatchService).sendTestPush(123L, "오늘의 질문", "지금 확인해보세요"); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 테스트 발송하면 예외를 던진다") + void sendTest_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.sendTest( + 999L, new AdminNotificationScheduleTestRequest(123L))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("예약 알림을 삭제한다") + void delete_removesExistingSchedule() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + adminNotificationScheduleService.delete(1L); + + verify(notificationScheduleRepository).delete(schedule); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 삭제하면 예외를 던진다") + void delete_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.delete(999L)) + .isInstanceOf(CustomException.class); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java new file mode 100644 index 00000000..ad78960c --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java @@ -0,0 +1,155 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.common.exception.CustomException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationDispatchService notificationDispatchService; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private NotificationDeliveryResultRepository notificationDeliveryResultRepository; + + @InjectMocks + private AdminNotificationService adminNotificationService; + + private Notification newNotice(String title, String body) { + return Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title(title) + .body(body) + .build(); + } + + @Test + @DisplayName("공지사항을 수정한다") + void updateNotice_updatesExistingNotice() { + Notification notification = newNotice("원본 제목", "원본 본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + AdminNoticeDetailResponse response = adminNotificationService.updateNotice( + 1L, new AdminNoticeUpdateRequest("수정된 제목", "수정된 본문")); + + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.body()).isEqualTo("수정된 본문"); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 수정하면 예외를 던진다") + void updateNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.updateNotice( + 999L, new AdminNoticeUpdateRequest("제목", "본문"))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지사항을 삭제하면 deletedAt이 기록된다") + void deleteNotice_softDeletesExistingNotice() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + adminNotificationService.deleteNotice(1L); + + assertThat(notification.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 삭제하면 예외를 던진다") + void deleteNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.deleteNotice(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지사항 발송 결과를 조회한다") + void getDeliveryResult_returnsResult() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + NotificationDeliveryResult deliveryResult = NotificationDeliveryResult.builder() + .notificationId(1L) + .targetCount(10) + .build(); + when(notificationDeliveryResultRepository.findByNotificationId(1L)) + .thenReturn(Optional.of(deliveryResult)); + + var response = adminNotificationService.getDeliveryResult(1L); + + assertThat(response.notificationId()).isEqualTo(1L); + assertThat(response.targetCount()).isEqualTo(10); + assertThat(response.successCount()).isEqualTo(0); + assertThat(response.failureCount()).isEqualTo(0); + assertThat(response.pending()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 공지사항의 발송 결과를 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenNoticeNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(999L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("발송 결과가 아직 집계되지 않은 공지사항을 조회하면 예외를 던진다") + void getDeliveryResult_throws_whenResultNotFound() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + when(notificationDeliveryResultRepository.findByNotificationId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.getDeliveryResult(1L)) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지 발송 대상자 수를 조회한다") + void getTargetCount_returnsCount() { + when(notificationDispatchService.countAdminNoticeTargets()).thenReturn(1200); + + var response = adminNotificationService.getTargetCount(); + + assertThat(response.targetCount()).isEqualTo(1200); + } + + @Test + @DisplayName("공지 작성 시 선택 가능한 카테고리 목록을 조회한다") + void getOptions_returnsCreatableCategories() { + var response = adminNotificationService.getOptions(); + + assertThat(response.categories()).containsExactly("CONTENT", "NOTICE", "EVENT"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java new file mode 100644 index 00000000..2c959ab6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminUserServiceTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.user.response.AdminUserSearchResponse; +import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminUserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserSocialAccountRepository userSocialAccountRepository; + + @InjectMocks + private AdminUserService adminUserService; + + private User newUser(long id, String userTag, String nickname) { + User user = User.builder() + .userTag(userTag) + .nickname(nickname) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Test + @DisplayName("닉네임 또는 유저태그로 매칭된 유저를 조회한다") + void searchUsers_matchesByNicknameOrUserTag() { + User user = newUser(1L, "picke_abcd", "민초러버"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("민초", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(1L); + assertThat(response.items().get(0).nickname()).isEqualTo("민초러버"); + assertThat(response.items().get(0).email()).isNull(); + } + + @Test + @DisplayName("이메일로 매칭된 소셜 로그인 유저를 조회하고 이메일을 함께 내려준다") + void searchUsers_matchesByEmail() { + User user = newUser(2L, "picke_efgh", "질문왕"); + UserSocialAccount socialAccount = UserSocialAccount.builder() + .user(user) + .provider("GOOGLE") + .providerUserId("google-1") + .providerEmail("user@gmail.com") + .build(); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(), pageable, 0)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())) + .thenReturn(List.of(socialAccount)); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of(socialAccount)); + + AdminUserSearchResponse response = adminUserService.searchUsers("gmail", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(2L); + assertThat(response.items().get(0).email()).isEqualTo("user@gmail.com"); + } + + @Test + @DisplayName("로컬 로그인 유저는 소셜 계정이 없어도 닉네임/유저태그로 조회된다") + void searchUsers_localLoginUser_hasNoEmail() { + User user = newUser(3L, "picke_ijkl", "로컬유저"); + Pageable pageable = PageRequest.of(0, 20); + when(userRepository.searchByNicknameOrUserTag(anyString(), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(user), pageable, 1)); + when(userSocialAccountRepository.findByProviderEmailContaining(anyString())).thenReturn(List.of()); + when(userSocialAccountRepository.findByUser_IdIn(any())).thenReturn(List.of()); + + AdminUserSearchResponse response = adminUserService.searchUsers("로컬", 0, 20); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).email()).isNull(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java new file mode 100644 index 00000000..6d042c57 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java @@ -0,0 +1,165 @@ +package com.swyp.picke.domain.battle.parser; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class BattleScriptDocumentParserTest { + + private final BattleScriptDocumentParser parser = new BattleScriptDocumentParser(); + + private String load(String name) throws IOException { + return new String(new ClassPathResource("battle-scripts/" + name).getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + } + + private ParsedNode node(BattleScriptDocument doc, String name) { + return doc.nodes.stream().filter(n -> n.name.equals(name)).findFirst().orElse(null); + } + + @Test + void 선형_대본_헤더와_메타데이터를_파싱한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-linear.txt")); + + assertThat(doc.interactive).isFalse(); + assertThat(doc.title).isEqualTo("인간은 본래 선한가, 악한가?"); + assertThat(doc.category).isEqualTo("철학"); + assertThat(doc.optionA.choiceName).isEqualTo("선하다"); + assertThat(doc.optionA.philosopherKeywords).containsExactly("노자", "플라톤", "석가모니"); + assertThat(doc.optionA.valueTags).containsExactly("내면", "이상"); + assertThat(doc.optionB.choiceName).isEqualTo("악하다"); + assertThat(doc.optionB.valueTags).containsExactly("원칙", "이성"); + } + + @Test + void 선형_대본은_오프닝_라운드_클로징으로_나뉜다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-linear.txt")); + + assertThat(doc.nodes).extracting(n -> n.name).containsExactly("오프닝", "라운드", "클로징"); + assertThat(node(doc, "오프닝").scripts).allMatch(s -> s.speaker == null); + assertThat(node(doc, "라운드").scripts).anyMatch(s -> "루소".equals(s.speaker)); + assertThat(node(doc, "라운드").scripts).anyMatch(s -> "홉스".equals(s.speaker)); + assertThat(node(doc, "클로징").scripts).hasSize(1); + assertThat(node(doc, "클로징").scripts.get(0).text).startsWith("루소는 말합니다"); + } + + @Test + void 인터랙티브_대본은_분기_버전을_노드_원본으로_쓴다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + + assertThat(doc.interactive).isTrue(); + assertThat(doc.title).isEqualTo("AI 판사는 도입해야 하는가?"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + } + + @Test + void 선택의_시간_노드는_A_B_옵션과_발화자를_담는다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + ParsedNode choice = node(doc, "선택"); + + assertThat(choice.options).hasSize(2); + assertThat(choice.options).extracting(o -> o.label).containsExactly("A", "B"); + assertThat(choice.options).extracting(o -> o.nextNodeName).containsExactly("분기_A", "분기_B"); + assertThat(choice.options).extracting(o -> o.speaker).containsExactly("칸트", "아리스토텔레스"); + assertThat(choice.options).extracting(o -> o.title).containsExactly( + "AI 판사를 도입해야 한다. 일관된 원칙이 진정한 공정함이다.", + "AI 판사는 도입해선 안 된다. 맥락을 이해하는 인간의 판단이 필요하다."); + } + + @Test + void 라운드_노드의_발화자_줄을_화자와_대사로_분리한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + ParsedNode round1 = node(doc, "1라운드"); + + assertThat(round1.scripts).hasSize(2); + assertThat(round1.scripts.get(0).speaker).isEqualTo("칸트"); + assertThat(round1.scripts.get(0).text).startsWith("정의는 보편적 원칙"); + assertThat(round1.scripts.get(1).speaker).isEqualTo("아리스토텔레스"); + } + + @Test + void 실제_대본_버전헤더없음_라운드부제_따옴표_인라인분기를_처리한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-1.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.interactive).isTrue(); + assertThat(doc.title).isEqualTo("첫 데이트 국밥, 무죄인가 유죄인가?"); + assertThat(doc.category).isEqualTo("관계·사랑"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + + // 발화자 주석 "(유죄)" 제거, 따옴표 제거 + assertThat(node(doc, "1라운드").scripts.get(0).speaker).isEqualTo("플라톤"); + assertThat(node(doc, "1라운드").scripts.get(0).text).startsWith("첫 데이트에서 국밥은"); + + // 사전 투표 줄에서 대표 발화자 + assertThat(doc.optionA.primarySpeaker).isEqualTo("플라톤"); + assertThat(doc.optionB.primarySpeaker).isEqualTo("마르크스"); + + // 공백 구분 철학자 키워드 + assertThat(doc.optionA.philosopherKeywords).containsExactly("플라톤", "칸트", "아리스토텔레스"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("마르크스", "니체", "사르트르"); + assertThat(doc.optionA.valueTags).containsExactly("원칙", "이상", "내면"); + + // [A 선택 시] 인라인 분기 + assertThat(node(doc, "분기_A").scripts.get(0).speaker).isEqualTo("플라톤"); + assertThat(node(doc, "분기_B").scripts.get(0).speaker).isEqualTo("마르크스"); + assertThat(node(doc, "선택").options).extracting(o -> o.speaker).containsExactly("플라톤", "마르크스"); + } + + @Test + void 실제_선형대본_클로징_텍스트가_같은_줄에_있어도_노드로_잡는다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-2.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.interactive).isFalse(); + assertThat(doc.title).isEqualTo("부모가 아이 이름에 종교적 의미를 담는 것은 정당한가?"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + // "[클로징] 당신의 이름은..." 인라인 텍스트가 클로징 노드 나레이션으로 들어간다 + assertThat(node(doc, "클로징").scripts).hasSize(1); + assertThat(node(doc, "클로징").scripts.get(0).speaker).isNull(); + assertThat(node(doc, "클로징").scripts.get(0).text).startsWith("당신의 이름은 누가 결정했습니까?"); + // 음(−)의 극 성향 지표도 파싱 + assertThat(doc.optionA.valueTags).containsExactly("관계", "전통"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("사르트르", "칸트", "니체"); + } + + @Test + void 여러_줄에_걸친_한_발언을_한_스크립트로_합친다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-3.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + assertThat(doc.optionA.primarySpeaker).isEqualTo("공자"); + assertThat(doc.optionB.primarySpeaker).isEqualTo("노자"); + + // "공자:" 뒤 콜론 공백 없음 + 이어지는 4줄이 한 발언 + ParsedNode round1 = node(doc, "1라운드"); + assertThat(round1.scripts).hasSize(2); + assertThat(round1.scripts.get(0).speaker).isEqualTo("공자"); + assertThat(round1.scripts.get(0).text) + .startsWith("생일 축하는 단순한 메시지가 아닙니다.") + .contains("무심함에 가깝습니다."); // 4번째 줄까지 합쳐짐 + assertThat(round1.scripts.get(1).speaker).isEqualTo("노자"); + + // 성향 지표에 12축 밖 문자열(자연)이 그대로 담긴다 — 검증은 조립 단계에서 + assertThat(doc.optionB.valueTags).containsExactly("개인", "자연", "내면"); + } + + @Test + void 인터랙티브_메타데이터도_양쪽_옵션을_파싱한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + + assertThat(doc.optionA.choiceName).isEqualTo("AI 판사를 도입해야 한다"); + assertThat(doc.optionA.philosopherKeywords).containsExactly("칸트", "플라톤", "소크라테스"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("아리스토텔레스", "공자", "사르트르"); + assertThat(doc.optionB.valueTags).containsExactly("개인", "변화"); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java index 53b664e7..da0ef1f0 100644 --- a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java @@ -60,8 +60,8 @@ void propose_Success() { // then // 제안 저장 메서드가 호출되었는지 확인 verify(battleProposalRepository, times(1)).save(any()); - // 크레딧 차감(-30) 로직이 호출되었는지 확인 - verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-30), any()); + // 크레딧 차감(-100) 로직이 호출되었는지 확인 + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-100), any()); } @Test diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleQueryServiceTest.java new file mode 100644 index 00000000..6ba5ca9e --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleQueryServiceTest.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.entity.BattleOptionTag; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.BattleTagRepository; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleQueryServiceTest { + + @Mock + private BattleRepository battleRepository; + @Mock + private BattleOptionRepository battleOptionRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleOptionTagRepository battleOptionTagRepository; + + @InjectMocks + private BattleQueryService battleQueryService; + + @Test + @DisplayName("철학자 유형 집계에서 제거된 철학자 태그를 처리하지 않는다") + void getTopPhilosopher_ignoresUnsupportedTags() { + BattleOptionTag supported = optionTag("플라톤"); + BattleOptionTag removed = optionTag("데카르트"); + when(battleOptionTagRepository.findByBattleOptionIdIn(List.of(1L))) + .thenReturn(List.of(removed, removed, supported)); + + assertThat(battleQueryService.getTopPhilosopherTagNameFromOptions(List.of(1L))) + .contains("플라톤"); + } + + private BattleOptionTag optionTag(String name) { + return BattleOptionTag.builder() + .tag(Tag.builder().name(name).type(TagType.PHILOSOPHER).build()) + .build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java new file mode 100644 index 00000000..128fdd36 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java @@ -0,0 +1,296 @@ +package com.swyp.picke.domain.battle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioNodeRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.parser.BattleScriptDocumentParser; +import com.swyp.picke.domain.battle.parser.EmotionClassifier; +import com.swyp.picke.domain.battle.parser.ScriptToneExtractor; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BattleScriptParseServiceTest { + + @Mock + private EmotionClassifier emotionClassifier; + @Mock + private PhilosopherVoiceService philosopherVoiceService; + @Mock + private TagRepository tagRepository; + + private BattleScriptParseService service; + + private final AtomicLong tagIdSeq = new AtomicLong(1); + + private Tag tag(String name, TagType type) { + Tag t = Tag.builder().name(name).type(type).build(); + ReflectionTestUtils.setField(t, "id", tagIdSeq.getAndIncrement()); + return t; + } + + @BeforeEach + void setUp() { + service = new BattleScriptParseService( + new BattleScriptDocumentParser(), new ScriptToneExtractor(), + emotionClassifier, philosopherVoiceService, tagRepository); + ReflectionTestUtils.setField(service, "narratorVoice", "voice-narrator"); + ReflectionTestUtils.setField(service, "userVoice", "voice-user"); + + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.CATEGORY)) + .thenReturn(List.of(tag("철학", TagType.CATEGORY), tag("관계·사랑", TagType.CATEGORY), + tag("관계", TagType.CATEGORY))); + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.PHILOSOPHER)) + .thenReturn(List.of( + tag("노자", TagType.PHILOSOPHER), tag("플라톤", TagType.PHILOSOPHER), + tag("석가모니", TagType.PHILOSOPHER), tag("칸트", TagType.PHILOSOPHER), + tag("아리스토텔레스", TagType.PHILOSOPHER), tag("마르크스", TagType.PHILOSOPHER), + tag("소크라테스", TagType.PHILOSOPHER), tag("공자", TagType.PHILOSOPHER), + tag("사르트르", TagType.PHILOSOPHER), tag("니체", TagType.PHILOSOPHER), + tag("노자", TagType.PHILOSOPHER))); + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.VALUE)) + .thenReturn(List.of( + tag("내면", TagType.VALUE), tag("이상", TagType.VALUE), + tag("원칙", TagType.VALUE), tag("이성", TagType.VALUE), + tag("개인", TagType.VALUE), tag("변화", TagType.VALUE))); + + lenient().when(philosopherVoiceService.findReferenceIdByName(anyString())) + .thenAnswer(inv -> Optional.of("voice-" + inv.getArgument(0, String.class).strip())); + lenient().when(emotionClassifier.classify(anyString(), any())).thenReturn(Map.of()); + } + + private String load(String name) throws IOException { + return new String(new ClassPathResource("battle-scripts/" + name).getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + } + + private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name) { + return res.scenarioPayload().nodes().stream() + .filter(n -> n.nodeName().equals(name)).findFirst().orElseThrow(); + } + + @Test + void 선형_대본을_배틀_페이로드로_조립한다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().title()).isEqualTo("인간은 본래 선한가, 악한가?"); + assertThat(res.battlePayload().status()).isEqualTo(BattleStatus.PENDING); + assertThat(res.battlePayload().summary()).startsWith("재난이 일어났을 때"); + assertThat(res.battlePayload().tagIds()).hasSize(1); // 철학 + assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title) + .containsExactly("선하다", "악하다"); + assertThat(res.battlePayload().options().get(0).representative()).isEqualTo("루소"); + assertThat(res.battlePayload().options().get(1).representative()).isEqualTo("홉스"); + // A안: 철학자 3 + 성향 2 = 5개 태그 + assertThat(res.battlePayload().options().get(0).tagIds()).hasSize(5); + } + + @Test + void 선형_대본_시나리오_노드가_오프닝_라운드_클로징_체인으로_연결된다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.scenarioPayload().isInteractive()).isFalse(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "라운드", "클로징"); + assertThat(node(res, "오프닝").isStartNode()).isTrue(); + assertThat(node(res, "오프닝").autoNextNode()).isEqualTo("라운드"); + assertThat(node(res, "라운드").autoNextNode()).isEqualTo("클로징"); + assertThat(node(res, "클로징").autoNextNode()).isNull(); + assertThat(node(res, "라운드").scripts().get(0).speakerType()).isEqualTo(SpeakerType.A); + assertThat(node(res, "오프닝").scripts().get(0).speakerType()).isEqualTo(SpeakerType.NARRATOR); + } + + @Test + void 선형_대본_보이스가_발화자별로_채워진다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + Map voices = res.scenarioPayload().voiceSettings(); + assertThat(voices).containsEntry(SpeakerType.A, "voice-루소") + .containsEntry(SpeakerType.B, "voice-홉스") + .containsEntry(SpeakerType.NARRATOR, "voice-narrator"); + assertThat(res.warnings()).noneMatch(w -> w.blocking()); + } + + @Test + void 인터랙티브_대본은_선택_노드와_분기_연결을_만든다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + assertThat(res.scenarioPayload().isInteractive()).isTrue(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + + AdminScenarioNodeRequest choice = node(res, "선택"); + assertThat(choice.autoNextNode()).isNull(); + assertThat(choice.interactiveOptions()).extracting(o -> o.label()).containsExactly("A", "B"); + assertThat(choice.interactiveOptions()).extracting(o -> o.nextNodeName()) + .containsExactly("분기_A", "분기_B"); + assertThat(node(res, "분기_A").autoNextNode()).isEqualTo("클로징"); + assertThat(node(res, "분기_B").autoNextNode()).isEqualTo("클로징"); + } + + @Test + void 인터랙티브_대본은_선택의_시간_명시로_A_B_발화자를_바인딩한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + assertThat(res.speakerNames()).containsEntry("A", "칸트").containsEntry("B", "아리스토텔레스"); + assertThat(node(res, "1라운드").scripts().get(0).speakerType()).isEqualTo(SpeakerType.A); // 칸트 + assertThat(node(res, "1라운드").scripts()).last().extracting(s -> s.speakerType()) + .isEqualTo(SpeakerType.B); // 아리스토텔레스 (문장 단위로 쪼개져 있을 수 있음) + } + + @Test + void 실제_대본을_인터랙티브_페이로드로_조립한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-real-1.txt")); + + assertThat(res.battlePayload().title()).isEqualTo("첫 데이트 국밥, 무죄인가 유죄인가?"); + assertThat(res.battlePayload().tagIds()).hasSize(1); // 관계·사랑 + assertThat(res.speakerNames()).containsEntry("A", "플라톤").containsEntry("B", "마르크스"); + assertThat(res.scenarioPayload().isInteractive()).isTrue(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + assertThat(node(res, "선택").interactiveOptions()).extracting(o -> o.nextNodeName()) + .containsExactly("분기_A", "분기_B"); + // 선택의 시간 줄의 설명 텍스트("A: {title} (철학자)")가 title 로 담긴다 + assertThat(node(res, "선택").interactiveOptions()).extracting(o -> o.title()) + .containsExactly( + "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!", + "가성비 좋고 든든하며, 솔직한 게 최고다, 무죄!"); + assertThat(node(res, "분기_A").autoNextNode()).isEqualTo("클로징"); + assertThat(res.scenarioPayload().voiceSettings()) + .containsEntry(SpeakerType.A, "voice-플라톤") + .containsEntry(SpeakerType.B, "voice-마르크스"); + assertThat(res.warnings()).noneMatch(w -> w.blocking()); + // 옵션 태그: 철학자 3 + 성향 3 = 6 + assertThat(res.battlePayload().options().get(0).tagIds()).hasSize(6); + } + + @Test + void 선택지_title은_사전투표의_짧은_이름을_우선한다() throws IOException { + // sample-real-1.txt: 사전 투표 = "유죄다"/"무죄다", 메타데이터 선택지 명칭 = 훨씬 긴 문장 + AdminBattleParseResponse res = service.parse(load("sample-real-1.txt")); + + assertThat(res.battlePayload().options().get(0).title()).isEqualTo("유죄다"); + assertThat(res.battlePayload().options().get(1).title()).isEqualTo("무죄다"); + } + + @Test + void 사전투표가_없으면_메타데이터_선택지_명칭을_title로_쓴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + // sample-linear.txt 는 사전 투표 없음 → 메타데이터 "선택지 명칭"(선하다/악하다) 그대로 + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title) + .containsExactly("선하다", "악하다"); + } + + @Test + void summary는_LLM_한줄요약_description은_오프닝_전문을_담는다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())) + .thenReturn(Optional.of("인간은 선한가 악한가, 재난 앞에서 드러나는 본성")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo("인간은 선한가 악한가, 재난 앞에서 드러나는 본성"); + assertThat(res.battlePayload().description()).startsWith("재난이 일어났을 때"); + assertThat(res.warnings()).noneMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + + @Test + void 요약_생성_실패시_오프닝_원문으로_대체하고_경고를_남긴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())).thenReturn(Optional.empty()); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo(res.battlePayload().description()); + assertThat(res.warnings()).anyMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + + @Test + void 사전투표_대표발화자로_A_B를_바인딩하고_미지원_성향지표는_경고한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-real-3.txt")); + + // 사전 투표: 공자→A, 노자→B (LLM 호출 없이) + assertThat(res.speakerNames()).containsEntry("A", "공자").containsEntry("B", "노자"); + assertThat(res.scenarioPayload().isInteractive()).isFalse(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + // 12축 밖 "자연" 은 태그로 안 들어가고 경고 + assertThat(res.warnings()).anyMatch(w -> w.code().equals("UNKNOWN_VALUE_TAG") + && "자연".equals(w.context())); + // B안 태그: 철학자 3 + 성향 2(개인,내면; 자연 제외) = 5 + assertThat(res.battlePayload().options().get(1).tagIds()).hasSize(5); + } + + @Test + void 한_발언은_문장_종결부호_기준으로_여러_스크립트로_쪼개진다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + // 루소의 첫 발언: "인간은 자연 상태에서 평화로웠습니다. 경쟁도, 소유욕도, 증오도 — 이건 문명이 만든 겁니다. + // 아이를 보십시오. 태어난 아이는 나누고 웃고, 타인의 고통에 울음으로 반응합니다. 악은 가르쳐지는 것입니다." + // → 문장 5개로 쪼개져야 한다. + var scripts = node(res, "라운드").scripts(); + long firstSpeakerSentences = scripts.stream() + .takeWhile(s -> "루소".equals(s.speakerName())) + .count(); + + assertThat(firstSpeakerSentences).isEqualTo(5); + assertThat(scripts.get(0).text()).isEqualTo("인간은 자연 상태에서 평화로웠습니다."); + assertThat(scripts.get(1).text()).startsWith("경쟁도, 소유욕도, 증오도"); + assertThat(scripts.get(2).text()).isEqualTo("아이를 보십시오."); + assertThat(scripts.get(4).text()).isEqualTo("악은 가르쳐지는 것입니다."); + // 같은 발언에서 쪼개진 문장들은 같은 화자·같은 톤을 유지한다 + assertThat(scripts.subList(0, 5)).allMatch(s -> "루소".equals(s.speakerName())); + } + + @Test + void LLM_실패시_톤은_전부_NEUTRAL이고_경고를_남긴다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + List tones = new ArrayList<>(); + res.scenarioPayload().nodes().forEach(n -> n.scripts().forEach(s -> tones.add(s.tone()))); + assertThat(tones).containsOnly(com.swyp.picke.domain.scenario.enums.Tone.NEUTRAL); + assertThat(res.warnings()).anyMatch(w -> w.code().equals("LLM_CLASSIFY_FAILED")); + } +} diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java new file mode 100644 index 00000000..61b61f68 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java @@ -0,0 +1,176 @@ +package com.swyp.picke.domain.battle.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.battle.converter.BattleConverter; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.entity.BattleOptionTag; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.BattleTagRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.scenario.repository.ScenarioRepository; +import com.swyp.picke.domain.scenario.service.ScenarioAudioPipelineService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import com.swyp.picke.domain.user.service.UserBattleService; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplTest { + + @Mock private BattleRepository battleRepository; + @Mock private BattleOptionRepository battleOptionRepository; + @Mock private BattleTagRepository battleTagRepository; + @Mock private BattleOptionTagRepository battleOptionTagRepository; + @Mock private TagRepository tagRepository; + @Mock private UserRepository userRepository; + @Mock private BattleVoteRepository battleVoteRepository; + @Mock private BattleConverter battleConverter; + @Mock private S3UploadService s3UploadService; + @Mock private LocalDraftFileStorageService localDraftFileStorageService; + @Mock private UserBattleService userBattleService; + @Mock private BattleAutoSeedService battleAutoSeedService; + @Mock private ScenarioRepository scenarioRepository; + @Mock private ScenarioAudioPipelineService scenarioAudioPipelineService; + @Mock private NotificationDispatchService notificationDispatchService; + + private BattleServiceImpl service; + + @BeforeEach + void setUp() { + service = new BattleServiceImpl( + battleRepository, battleOptionRepository, battleTagRepository, battleOptionTagRepository, + tagRepository, userRepository, battleVoteRepository, battleConverter, s3UploadService, + localDraftFileStorageService, userBattleService, battleAutoSeedService, scenarioRepository, + scenarioAudioPipelineService, notificationDispatchService); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("제목").status(BattleStatus.PENDING) + .creatorType(BattleCreatorType.ADMIN) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private BattleOption option(Long id, Battle battle) { + BattleOption option = BattleOption.builder() + .battle(battle).title("기존 옵션").displayOrder(1).build(); + ReflectionTestUtils.setField(option, "id", id); + return option; + } + + @Test + void 옵션_태그를_교체할때_기존_태그_삭제를_먼저_flush한_뒤_새_태그를_저장한다() { + Battle battle = battle(22L); + BattleOption existingOption = option(42L, battle); + Tag tag = Tag.builder().name("칸트").type(TagType.PHILOSOPHER).build(); + ReflectionTestUtils.setField(tag, "id", 6L); + BattleOptionTag existingOptionTag = BattleOptionTag.builder() + .battleOption(existingOption).tag(tag).build(); + + when(battleRepository.findById(22L)).thenReturn(Optional.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of(existingOption)); + when(battleOptionTagRepository.findByBattleOption(existingOption)).thenReturn(List.of(existingOptionTag)); + when(tagRepository.findAllById(anyList())).thenReturn(List.of(tag)); + lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of()); + lenient().when(battleOptionRepository.save(any(BattleOption.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + AdminBattleOptionRequest optionRequest = new AdminBattleOptionRequest( + "기존 옵션", null, null, null, 1, List.of(6L)); + AdminBattleUpdateRequest request = new AdminBattleUpdateRequest( + "제목", null, null, null, null, null, null, BattleStatus.PENDING, + null, List.of(optionRequest, optionRequest2())); + + service.updateBattle(22L, request); + + // 같은 (battleOption, tag) 조합을 다시 저장하는 경우, delete가 flush로 먼저 반영된 뒤에 + // insert(save)가 나가야 유니크 제약 위반(23505)이 안 난다. + InOrder inOrder = inOrder(battleOptionTagRepository); + inOrder.verify(battleOptionTagRepository).deleteAll(List.of(existingOptionTag)); + inOrder.verify(battleOptionTagRepository).flush(); + inOrder.verify(battleOptionTagRepository).save(any(BattleOptionTag.class)); + } + + // updateBattle은 옵션 2~4개를 요구한다(validateBattleOptionCount) — 최소 개수 충족용 더미 옵션 + private AdminBattleOptionRequest optionRequest2() { + return new AdminBattleOptionRequest("두번째 옵션", null, null, null, 2, List.of()); + } + + @AfterEach + void tearDownSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void 발행_상태로_바뀌며_local_draft_썸네일이면_실제_S3_승격은_커밋_이후로_미룬다() { + Battle battle = battle(1L); + String localDraftKey = "local/drafts/uuid_thumb.png"; + String s3Key = "images/battles/uuid_thumb.png"; + + when(battleRepository.findById(1L)).thenReturn(Optional.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + lenient().when(localDraftFileStorageService.normalizeLocalDraftKey(localDraftKey)).thenReturn(localDraftKey); + when(localDraftFileStorageService.isLocalDraftReference(localDraftKey)).thenReturn(true); + when(localDraftFileStorageService.resolveS3Key(localDraftKey, FileCategory.BATTLE)).thenReturn(s3Key); + lenient().when(battleOptionRepository.save(any(BattleOption.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of()); + + AdminBattleUpdateRequest request = new AdminBattleUpdateRequest( + "제목", null, null, localDraftKey, null, null, null, BattleStatus.PUBLISHED, + null, List.of( + new AdminBattleOptionRequest("옵션1", null, null, null, 1, null), + new AdminBattleOptionRequest("옵션2", null, null, null, 2, null))); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.updateBattle(1L, request); + + // 요청 처리 도중(커밋 전)에는 실제 업로드/삭제가 절대 일어나면 안 된다. + verify(localDraftFileStorageService, never()).promoteToS3(anyString(), anyString(), any()); + org.assertj.core.api.Assertions.assertThat(battle.getThumbnailUrl()).isEqualTo(s3Key); + + // 커밋 이후에야 실제 승격이 일어난다. + TransactionSynchronizationManager.getSynchronizations() + .forEach(org.springframework.transaction.support.TransactionSynchronization::afterCommit); + verify(localDraftFileStorageService).promoteToS3(localDraftKey, s3Key, s3UploadService); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/controller/NotificationTestControllerTest.java b/src/test/java/com/swyp/picke/domain/notification/controller/NotificationTestControllerTest.java new file mode 100644 index 00000000..7f48771f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/controller/NotificationTestControllerTest.java @@ -0,0 +1,30 @@ +package com.swyp.picke.domain.notification.controller; + +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.service.NotificationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class NotificationTestControllerTest { + + private final NotificationService notificationService = mock(NotificationService.class); + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new NotificationTestController(notificationService)).build(); + + @Test + void createTestNotification_callsServiceWithGivenParams() throws Exception { + mockMvc.perform(post("/api/test/notifications") + .param("userId", "1") + .param("detailCode", "NEW_BATTLE") + .param("body", "테스트 알림 본문") + .param("referenceId", "100")) + .andExpect(status().isOk()); + + verify(notificationService).createNotification(1L, NotificationDetailCode.NEW_BATTLE, "테스트 알림 본문", 100L); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/entity/NotificationScheduleTest.java b/src/test/java/com/swyp/picke/domain/notification/entity/NotificationScheduleTest.java new file mode 100644 index 00000000..0db999fc --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/entity/NotificationScheduleTest.java @@ -0,0 +1,89 @@ +package com.swyp.picke.domain.notification.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class NotificationScheduleTest { + + @Test + @DisplayName("활성화 상태이고 발송 시각(분 단위)이 같고 오늘 아직 발송되지 않았다면 발송 대상이다") + void isDue_true_whenEnabledAndTimeMatchesAndNotSentToday() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + + boolean due = schedule.isDue(LocalTime.of(19, 0, 37), LocalDate.of(2026, 8, 8)); + + assertThat(due).isTrue(); + } + + @Test + @DisplayName("비활성화 상태면 시각이 같아도 발송 대상이 아니다") + void isDue_false_whenDisabled() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(false) + .build(); + + boolean due = schedule.isDue(LocalTime.of(19, 0), LocalDate.of(2026, 8, 8)); + + assertThat(due).isFalse(); + } + + @Test + @DisplayName("발송 시각(분 단위)이 다르면 발송 대상이 아니다") + void isDue_false_whenTimeDoesNotMatch() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + + boolean due = schedule.isDue(LocalTime.of(19, 1), LocalDate.of(2026, 8, 8)); + + assertThat(due).isFalse(); + } + + @Test + @DisplayName("같은 날 이미 발송했다면 다시 발송 대상이 되지 않는다") + void isDue_false_whenAlreadySentToday() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + LocalDate today = LocalDate.of(2026, 8, 8); + schedule.markSent(today); + + boolean due = schedule.isDue(LocalTime.of(19, 0), today); + + assertThat(due).isFalse(); + } + + @Test + @DisplayName("이전에 발송했더라도 날짜가 바뀌면 다시 발송 대상이 된다") + void isDue_true_whenSentOnDifferentDay() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + schedule.markSent(LocalDate.of(2026, 8, 7)); + + boolean due = schedule.isDue(LocalTime.of(19, 0), LocalDate.of(2026, 8, 8)); + + assertThat(due).isTrue(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java new file mode 100644 index 00000000..42c1306f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/scheduler/NotificationScheduleDispatcherTest.java @@ -0,0 +1,103 @@ +package com.swyp.picke.domain.notification.scheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.entity.NotificationSchedule; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationScheduleRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.notification.service.NotificationService; +import java.time.Clock; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NotificationScheduleDispatcherTest { + + private static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); + + @Mock + private NotificationScheduleRepository notificationScheduleRepository; + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationDispatchService notificationDispatchService; + + private NotificationScheduleDispatcher notificationScheduleDispatcher; + + @BeforeEach + void setUp() { + Clock fixedClock = Clock.fixed( + LocalTime.of(9, 0).atDate(java.time.LocalDate.now(SEOUL_ZONE)).atZone(SEOUL_ZONE).toInstant(), + SEOUL_ZONE); + notificationScheduleDispatcher = new NotificationScheduleDispatcher( + notificationScheduleRepository, notificationService, notificationDispatchService, fixedClock); + } + + @Test + @DisplayName("현재 시각과 일치하는 활성 예약만 발송하고 발송일을 기록한다") + void dispatchDueSchedules_sendsOnlyMatchingEnabledSchedules() { + LocalTime now = LocalTime.of(9, 0); + NotificationSchedule due = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(now.getHour(), now.getMinute())) + .enabled(true) + .build(); + NotificationSchedule notDue = NotificationSchedule.builder() + .title("다른 알림") + .subtitle("다른 소제목") + .sendTime(LocalTime.of(now.getHour(), now.getMinute()).plusHours(1)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findAllByEnabledTrue()).thenReturn(List.of(due, notDue)); + + Notification notification = Notification.builder() + .user(null) + .category(NotificationCategory.CONTENT) + .detailCode(NotificationDetailCode.DAILY_MESSAGE) + .title("오늘의 질문") + .body("지금 확인해보세요") + .build(); + ReflectionTestUtils.setField(notification, "id", 1L); + when(notificationService.createBroadcastNotification( + eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any())) + .thenReturn(notification); + + notificationScheduleDispatcher.dispatchDueSchedules(); + + verify(notificationService, times(1)).createBroadcastNotification( + eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요"), any()); + verify(notificationDispatchService, times(1)).notifyAdminNotice( + eq(1L), eq(NotificationDetailCode.DAILY_MESSAGE), eq("오늘의 질문"), eq("지금 확인해보세요")); + verify(notificationDispatchService, never()).notifyAdminNotice( + any(), eq(NotificationDetailCode.DAILY_MESSAGE), eq("다른 알림"), any()); + } + + @Test + @DisplayName("발송 대상 예약이 없으면 아무것도 발송하지 않는다") + void dispatchDueSchedules_doesNothing_whenNoScheduleIsDue() { + when(notificationScheduleRepository.findAllByEnabledTrue()).thenReturn(List.of()); + + notificationScheduleDispatcher.dispatchDueSchedules(); + + verify(notificationDispatchService, never()).notifyAdminNotice(any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java new file mode 100644 index 00000000..04d7532e --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationDispatchServiceTest.java @@ -0,0 +1,98 @@ +package com.swyp.picke.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.notification.entity.NotificationDeliveryResult; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationDeliveryResultRepository; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import com.swyp.picke.domain.user.repository.UserSettingsRepository; +import com.swyp.picke.global.infra.apns.service.ApnsPushService; +import com.swyp.picke.global.infra.fcm.service.FcmPushService; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NotificationDispatchServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserSettingsRepository userSettingsRepository; + + @Mock + private NotificationDeliveryResultRepository notificationDeliveryResultRepository; + + @Mock + private FcmPushService fcmPushService; + + @Mock + private ApnsPushService apnsPushService; + + private NotificationDispatchService newService() { + NotificationDispatchService service = new NotificationDispatchService( + notificationService, userDeviceRepository, userSettingsRepository, + notificationDeliveryResultRepository, fcmPushService, apnsPushService); + ReflectionTestUtils.setField(service, "baseUrl", "https://picke.store"); + return service; + } + + private UserDevice newDevice() { + return UserDevice.builder().fcmToken("token-" + Math.random()).platform(DevicePlatform.ANDROID).build(); + } + + @Test + @DisplayName("공지 발송 시 대상 디바이스 수로 발송 결과 row를 먼저 만들고, 발송 완료 후 성공/실패 건수를 갱신한다") + void notifyAdminNotice_recordsDeliveryResult() { + NotificationDispatchService notificationDispatchService = newService(); + + UserDevice success = newDevice(); + UserDevice failure = newDevice(); + when(userSettingsRepository.findUserIdsByMarketingEventEnabledTrue()).thenReturn(List.of(1L, 2L)); + when(userDeviceRepository.findAllByUserIdIn(List.of(1L, 2L))).thenReturn(List.of(success, failure)); + when(fcmPushService.send(eq(success), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(true)); + when(fcmPushService.send(eq(failure), anyString(), anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(false)); + + notificationDispatchService.notifyAdminNotice(10L, NotificationDetailCode.POLICY_CHANGE, "제목", "본문"); + + ArgumentCaptor createdCaptor = ArgumentCaptor.forClass(NotificationDeliveryResult.class); + verify(notificationDeliveryResultRepository).save(createdCaptor.capture()); + assertThat(createdCaptor.getValue().getNotificationId()).isEqualTo(10L); + assertThat(createdCaptor.getValue().getTargetCount()).isEqualTo(2); + + verify(notificationDeliveryResultRepository).updateResult(10L, 1, 1); + } + + @Test + @DisplayName("공지 발송 대상자 수를 마케팅/이벤트 알림 설정 ON인 유저의 디바이스 수 기준으로 조회한다") + void countAdminNoticeTargets_returnsDeviceCount() { + NotificationDispatchService notificationDispatchService = newService(); + + when(userSettingsRepository.findUserIdsByMarketingEventEnabledTrue()).thenReturn(List.of(1L, 2L, 3L)); + when(userDeviceRepository.countByUserIdIn(List.of(1L, 2L, 3L))).thenReturn(5L); + + int targetCount = notificationDispatchService.countAdminNoticeTargets(); + + assertThat(targetCount).isEqualTo(5); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java index 69aea51d..f413779c 100644 --- a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -164,7 +164,7 @@ void getNotifications_includes_content_broadcast_notifications() { @Test @DisplayName("존재하지 않는 알림 읽음 처리 시 예외를 던진다") void markAsRead_throws_when_not_found() { - when(notificationRepository.findById(999L)).thenReturn(Optional.empty()); + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> notificationService.markAsRead(1L, 999L)) .isInstanceOf(CustomException.class); @@ -184,7 +184,7 @@ void markAsRead_saves_notification_read_for_broadcast() { .referenceId(50L) .build(); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); notificationService.markAsRead(userId, notificationId); @@ -209,7 +209,7 @@ void getNotificationDetail_returns_owned_notification() { setUserId(user, userId); setNotificationId(notification, 10L); - when(notificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(notification)); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, 10L); @@ -235,7 +235,7 @@ void getNotificationDetail_returns_broadcast_notification() { setNotificationId(notification, notificationId); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, notificationId); @@ -263,7 +263,7 @@ void getNotificationDetail_throws_when_notification_not_accessible() { .build(); setUserId(owner, ownerId); - when(notificationRepository.findById(30L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(30L)).thenReturn(Optional.of(notification)); assertThatThrownBy(() -> notificationService.getNotificationDetail(requesterId, 30L)) .isInstanceOf(CustomException.class); @@ -283,6 +283,30 @@ void markAllAsRead_calls_repository() { verify(notificationReadRepository).markAllBroadcastAsRead(userId); } + @Test + @DisplayName("ALL 카테고리로 미읽음 여부를 확인하면 카테고리 필터 없이 조회한다") + void hasUnread_withAllCategory_queriesWithoutCategoryFilter() { + Long userId = 1L; + when(notificationRepository.existsUnread(userId, null)).thenReturn(true); + + var response = notificationService.hasUnread(userId, NotificationCategory.ALL); + + assertThat(response.hasUnread()).isTrue(); + verify(notificationRepository).existsUnread(userId, null); + } + + @Test + @DisplayName("카테고리를 지정하면 해당 카테고리로 미읽음 여부를 확인한다") + void hasUnread_withSpecificCategory_queriesWithCategoryFilter() { + Long userId = 1L; + when(notificationRepository.existsUnread(userId, NotificationCategory.CONTENT)).thenReturn(false); + + var response = notificationService.hasUnread(userId, NotificationCategory.CONTENT); + + assertThat(response.hasUnread()).isFalse(); + verify(notificationRepository).existsUnread(userId, NotificationCategory.CONTENT); + } + private User createMockUser() { return User.builder() .userTag("test-user-tag") diff --git a/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java new file mode 100644 index 00000000..563781c4 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/oauth/jwt/JwtFilterTest.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.oauth.jwt; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; +import jakarta.servlet.FilterChain; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private JwtProvider jwtProvider; + + @Mock + private UserDailyActivityRepository userDailyActivityRepository; + + @Mock + private FilterChain filterChain; + + @Test + void doFilterInternal_유효한_토큰이면_오늘자_활동을_기록한다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(userDailyActivityRepository).markActive(eq(42L), any(LocalDate.class)); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_활동_기록이_실패해도_요청_처리는_계속된다() throws Exception { + JwtFilter jwtFilter = new JwtFilter(jwtProvider, userDailyActivityRepository); + + when(jwtProvider.validateToken("valid-token")).thenReturn(true); + when(jwtProvider.getUserId("valid-token")).thenReturn(42L); + when(jwtProvider.getRole("valid-token")).thenReturn("USER"); + doThrow(new RuntimeException("DB down")) + .when(userDailyActivityRepository).markActive(any(), any()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/me/mypage"); + request.addHeader("Authorization", "Bearer valid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + jwtFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } +} diff --git a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java index 4e44a078..81dce108 100644 --- a/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/oauth/service/OAuthServiceTest.java @@ -3,13 +3,19 @@ import com.swyp.picke.domain.oauth.client.GoogleOAuthClient; import com.swyp.picke.domain.oauth.client.KakaoOAuthClient; import com.swyp.picke.domain.oauth.client.AppleOAuthClient; +import com.swyp.picke.domain.admin.dto.testaccount.request.CreateTestAccountRequest; +import com.swyp.picke.domain.admin.dto.testaccount.response.TestAccountResponse; +import com.swyp.picke.domain.oauth.dto.LocalLoginRequest; import com.swyp.picke.domain.oauth.dto.LoginRequest; import com.swyp.picke.domain.oauth.dto.LoginResponse; import com.swyp.picke.domain.oauth.dto.OAuthUserInfo; import com.swyp.picke.domain.oauth.dto.WithdrawRequest; +import com.swyp.picke.domain.oauth.entity.UserLocalAccount; import com.swyp.picke.domain.oauth.entity.UserSocialAccount; +import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.domain.oauth.jwt.JwtProvider; import com.swyp.picke.domain.oauth.repository.AuthRefreshTokenRepository; +import com.swyp.picke.domain.oauth.repository.UserLocalAccountRepository; import com.swyp.picke.domain.oauth.repository.UserSocialAccountRepository; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.entity.User; @@ -18,6 +24,7 @@ import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.enums.WithdrawalReason; +import com.swyp.picke.domain.user.repository.UserDailyActivityRepository; import com.swyp.picke.domain.user.repository.UserProfileRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.domain.user.repository.UserSettingsRepository; @@ -30,10 +37,12 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; @@ -46,13 +55,16 @@ class OAuthServiceTest { @Mock private AppleOAuthClient appleOAuthClient; @Mock private UserRepository userRepository; @Mock private UserSocialAccountRepository socialAccountRepository; + @Mock private UserLocalAccountRepository localAccountRepository; @Mock private AuthRefreshTokenRepository refreshTokenRepository; @Mock private UserProfileRepository userProfileRepository; @Mock private UserSettingsRepository userSettingsRepository; @Mock private UserTendencyScoreRepository userTendencyScoreRepository; @Mock private UserWithdrawalRepository userWithdrawalRepository; + @Mock private UserDailyActivityRepository userDailyActivityRepository; @Mock private JwtProvider jwtProvider; @Mock private CreditService creditService; + @Mock private PasswordEncoder passwordEncoder; private AuthService authService; @@ -61,10 +73,10 @@ void setUp() { // 수동 주입으로 안정성 확보 authService = new AuthService( kakaoOAuthClient, googleOAuthClient, appleOAuthClient, userRepository, - socialAccountRepository, refreshTokenRepository, + socialAccountRepository, localAccountRepository, refreshTokenRepository, userProfileRepository, userSettingsRepository, userTendencyScoreRepository, - userWithdrawalRepository, - jwtProvider, creditService + userWithdrawalRepository, userDailyActivityRepository, + jwtProvider, creditService, passwordEncoder ); } @@ -102,6 +114,7 @@ void setUp() { assertThat(response.getAccessToken()).isEqualTo("jwt-access"); assertThat(response.isNewUser()).isFalse(); verify(refreshTokenRepository).save(any()); + verify(userDailyActivityRepository).markLoggedIn(eq(user.getId()), any(java.time.LocalDate.class)); } @Test @@ -211,6 +224,105 @@ void setUp() { verify(socialAccountRepository).save(socialAccount); } + @Test + void loginLocal_존재하는_계정과_올바른_비밀번호면_로그인_성공() { + User user = User.builder() + .userTag("pique-local") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + UserLocalAccount localAccount = UserLocalAccount.builder() + .user(user) + .username("qa-tester01") + .passwordHash("hashed-pw") + .build(); + + when(localAccountRepository.findByUsername("qa-tester01")).thenReturn(Optional.of(localAccount)); + when(passwordEncoder.matches("plain-pw", "hashed-pw")).thenReturn(true); + when(jwtProvider.createAccessToken(any(), anyString())).thenReturn("jwt-access"); + when(jwtProvider.createRefreshToken()).thenReturn("jwt-refresh"); + + LoginResponse response = authService.loginLocal(new LocalLoginRequest("qa-tester01", "plain-pw")); + + assertThat(response.getAccessToken()).isEqualTo("jwt-access"); + assertThat(response.isNewUser()).isFalse(); + verify(refreshTokenRepository).save(any()); + } + + @Test + void loginLocal_존재하지_않는_아이디면_인증오류를_던진다() { + when(localAccountRepository.findByUsername("no-such-user")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.loginLocal(new LocalLoginRequest("no-such-user", "plain-pw"))) + .isInstanceOf(CustomException.class); + + verify(jwtProvider, never()).createAccessToken(any(), anyString()); + } + + @Test + void loginLocal_비밀번호가_틀리면_인증오류를_던진다() { + User user = User.builder() + .userTag("pique-local") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + UserLocalAccount localAccount = UserLocalAccount.builder() + .user(user) + .username("qa-tester01") + .passwordHash("hashed-pw") + .build(); + + when(localAccountRepository.findByUsername("qa-tester01")).thenReturn(Optional.of(localAccount)); + when(passwordEncoder.matches("wrong-pw", "hashed-pw")).thenReturn(false); + + assertThatThrownBy(() -> authService.loginLocal(new LocalLoginRequest("qa-tester01", "wrong-pw"))) + .isInstanceOf(CustomException.class); + + verify(jwtProvider, never()).createAccessToken(any(), anyString()); + } + + @Test + void createLocalTestAccount_신규_유저와_로컬계정을_생성한다() { + CreateTestAccountRequest request = new CreateTestAccountRequest("qa-tester02", "plain-pw"); + + User savedUser = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + when(localAccountRepository.existsByUsername("qa-tester02")).thenReturn(false); + when(passwordEncoder.encode("plain-pw")).thenReturn("hashed-pw"); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + when(userRepository.save(userCaptor.capture())).thenReturn(savedUser); + + TestAccountResponse response = authService.createLocalTestAccount(request); + + assertThat(response.getUsername()).isEqualTo("qa-tester02"); + assertThat(response.getUserTag()).isEqualTo(userCaptor.getValue().getUserTag()); + + ArgumentCaptor localAccountCaptor = ArgumentCaptor.forClass(UserLocalAccount.class); + verify(localAccountRepository).save(localAccountCaptor.capture()); + assertThat(localAccountCaptor.getValue().getPasswordHash()).isEqualTo("hashed-pw"); + + verify(userProfileRepository).save(any()); + verify(userSettingsRepository).save(any()); + verify(userTendencyScoreRepository).save(any()); + } + + @Test + void createLocalTestAccount_이미_존재하는_아이디면_예외를_던진다() { + when(localAccountRepository.existsByUsername("dup-user")).thenReturn(true); + + assertThatThrownBy(() -> authService.createLocalTestAccount(new CreateTestAccountRequest("dup-user", "plain-pw"))) + .isInstanceOf(CustomException.class); + + verify(userRepository, never()).save(any()); + } + @Test void withdraw_탈퇴사유를_저장하고_사용자를_삭제처리한다() { User user = User.builder() diff --git a/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java b/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java index 8f36e2c4..43697a0e 100644 --- a/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/perspective/service/PerspectiveServiceTest.java @@ -5,6 +5,9 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.perspective.dto.request.CreatePerspectiveRequest; +import com.swyp.picke.domain.perspective.dto.response.CreatePerspectiveResponse; +import com.swyp.picke.domain.perspective.dto.response.MyPerspectiveResponse; import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; @@ -19,6 +22,7 @@ import com.swyp.picke.domain.vote.service.BattleVoteService; import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService; import java.time.LocalDate; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -90,6 +94,54 @@ void getPerspectiveDetail_returns_option_opinion_as_label() { assertThat(response.option().title()).isEqualTo("예술이 아니다"); } + @Test + @DisplayName("이미 관점을 남긴 배틀에도 새 관점을 추가로 등록할 수 있다") + void createPerspective_allows_multiple_perspectives_for_same_user_and_battle() { + Battle battle = battle(100L); + User user = user(1L); + BattleOption option = option(10L, battle, BattleOptionLabel.A, "예술이다"); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(battleVoteService.findPostVoteOptionId(100L, 1L)).thenReturn(10L); + when(battleService.findOptionById(10L)).thenReturn(option); + when(perspectiveRepository.save(org.mockito.ArgumentMatchers.any(Perspective.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + CreatePerspectiveResponse response = perspectiveService.createPerspective( + 100L, 1L, new CreatePerspectiveRequest("두 번째 관점")); + + assertThat(response).isNotNull(); + org.mockito.Mockito.verify(perspectiveRepository).save(org.mockito.ArgumentMatchers.any(Perspective.class)); + } + + @Test + @DisplayName("내 관점 조회는 한 배틀에 남긴 여러 관점을 목록으로 반환한다") + void getMyPerspective_returns_multiple_perspectives_as_list() { + Battle battle = battle(100L); + User user = user(1L); + BattleOption option = option(10L, battle, BattleOptionLabel.A, "예술이다"); + + Perspective first = Perspective.builder().battle(battle).user(user).option(option).content("첫 번째").build(); + first.publish(); + ReflectionTestUtils.setField(first, "id", 1L); + + Perspective second = Perspective.builder().battle(battle).user(user).option(option).content("두 번째").build(); + second.publish(); + ReflectionTestUtils.setField(second, "id", 2L); + + when(battleService.findById(100L)).thenReturn(battle); + when(perspectiveRepository.findByBattleIdAndUserIdOrderByCreatedAtDesc(100L, 1L)) + .thenReturn(List.of(second, first)); + when(userQueryService.findSummaryById(1L)).thenReturn(new UserSummary("user-1", "nick", "OWL")); + when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("character-url"); + + List responses = perspectiveService.getMyPerspective(100L, 1L); + + assertThat(responses).hasSize(2); + assertThat(responses).extracting(MyPerspectiveResponse::perspectiveId).containsExactly(2L, 1L); + } + private Battle battle(Long id) { Battle battle = Battle.builder() .title("battle") diff --git a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java index 016a5024..ca0bf39c 100644 --- a/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/reward/service/AdMobRewardServiceTest.java @@ -97,7 +97,7 @@ void processReward_Success() throws Exception { assertThat(result).isEqualTo("OK"); // // 4. 호출 검증 - verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.FREE_CHARGE), eq(100), anyLong()); + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.FREE_CHARGE), eq(CreditType.FREE_CHARGE.getDefaultAmount()), anyLong()); verify(adRewardHistoryRepository, times(1)).saveAndFlush(any(AdRewardHistory.class)); verify(userService, times(1)).findByUserTag("pique-1cc4a030"); } diff --git a/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java new file mode 100644 index 00000000..500c34a6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.scenario.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ToneTest { + + @Test + void fromInlineTag_기본_톤_태그를_해석한다() { + assertThat(Tone.fromInlineTag("[angry]")).contains(Tone.ANGRY); + assertThat(Tone.fromInlineTag("[excited]")).contains(Tone.EXCITED); + } + + @Test + void fromInlineTag_축약_표기도_흡수한다() { + assertThat(Tone.fromInlineTag("[soft]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[soft tone]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[whisper]")).contains(Tone.WHISPERING); + } + + @Test + void fromInlineTag_대괄호_없이_대소문자_섞여도_해석한다() { + assertThat(Tone.fromInlineTag(" Angry ")).contains(Tone.ANGRY); + } + + @Test + void fromInlineTag_효과_태그나_미지정은_empty() { + assertThat(Tone.fromInlineTag("[sighing]")).isEmpty(); + assertThat(Tone.fromInlineTag(null)).isEmpty(); + assertThat(Tone.fromInlineTag("[break]")).isEmpty(); + } + + @Test + void fromNameOrNeutral_null이거나_모르는_값이면_NEUTRAL() { + assertThat(Tone.fromNameOrNeutral(null)).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("")).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("ANGRY")).isEqualTo(Tone.ANGRY); + assertThat(Tone.fromNameOrNeutral("something-else")).isEqualTo(Tone.NEUTRAL); + } + + @Test + void NEUTRAL만_태그가_없다() { + assertThat(Tone.NEUTRAL.hasTag()).isFalse(); + for (Tone tone : Tone.values()) { + if (tone != Tone.NEUTRAL) { + assertThat(tone.hasTag()).isTrue(); + assertThat(tone.getTag()).startsWith("[").endsWith("]"); + } + } + assertThat(Optional.ofNullable(Tone.NEUTRAL.getTag())).isEmpty(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java new file mode 100644 index 00000000..c198a288 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java @@ -0,0 +1,166 @@ +package com.swyp.picke.domain.scenario.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PhilosopherVoiceServiceTest { + + @Mock + private PhilosopherVoiceRepository philosopherVoiceRepository; + + @Mock + private ResourceUrlProvider resourceUrlProvider; + + @InjectMocks + private PhilosopherVoiceService philosopherVoiceService; + + @BeforeEach + void setUp() { + // raw key를 그대로 돌려주는 기본 스텁(변환 여부 자체를 검증하는 테스트만 다르게 재정의) + lenient().when(resourceUrlProvider.getImageUrl(any(), any())) + .thenAnswer(invocation -> invocation.getArgument(1)); + } + + private PhilosopherVoice entity(String name, String referenceId) { + return PhilosopherVoice.builder() + .name(name) + .referenceId(referenceId) + .voiceLabel("label") + .build(); + } + + @Test + void findReferenceIdByName_등록된_철학자면_reference_id_반환() { + when(philosopherVoiceRepository.findByName("소크라테스")) + .thenReturn(Optional.of(entity("소크라테스", "voice-a"))); + + assertThat(philosopherVoiceService.findReferenceIdByName(" 소크라테스 ")) + .contains("voice-a"); + } + + @Test + void findReferenceIdByName_미등록이거나_빈값이면_empty() { + when(philosopherVoiceRepository.findByName("루소")).thenReturn(Optional.empty()); + + assertThat(philosopherVoiceService.findReferenceIdByName("루소")).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(null)).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(" ")).isEmpty(); + } + + @Test + void create_이름이_중복이면_예외() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(true); + + assertThatThrownBy(() -> philosopherVoiceService.create( + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + + verify(philosopherVoiceRepository, never()).save(any()); + } + + @Test + void create_정상이면_저장하고_응답_반환() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse response = philosopherVoiceService.create( + new PhilosopherVoiceRequest(" 칸트 ", " voice-x ", "Meursault", null, null)); + + assertThat(response.name()).isEqualTo("칸트"); + assertThat(response.referenceId()).isEqualTo("voice-x"); + verify(philosopherVoiceRepository).save(any(PhilosopherVoice.class)); + } + + @Test + void update_대상이_없으면_예외() { + when(philosopherVoiceRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> philosopherVoiceService.update(99L, + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + + @Test + void update_reference_id를_교체한다() { + PhilosopherVoice found = entity("칸트", "voice-old"); + when(philosopherVoiceRepository.findById(1L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse response = philosopherVoiceService.update(1L, + new PhilosopherVoiceRequest("칸트", "voice-new", "새 라벨", null, "메모")); + + assertThat(response.referenceId()).isEqualTo("voice-new"); + assertThat(found.getReferenceId()).isEqualTo("voice-new"); + } + + @Test + void 응답의_imageKey는_raw_저장키가_아니라_ResourceUrlProvider가_변환한_값이다() { + when(philosopherVoiceRepository.existsByName("루소")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, "images/philosophers/rousseau.png")) + .thenReturn("https://dev.picke.store/api/v1/resources/images/PHILOSOPHER/rousseau.png"); + + PhilosopherVoiceResponse response = philosopherVoiceService.create( + new PhilosopherVoiceRequest("루소", "voice-x", null, "images/philosophers/rousseau.png", null)); + + assertThat(response.imageKey()) + .isEqualTo("https://dev.picke.store/api/v1/resources/images/PHILOSOPHER/rousseau.png") + .isNotEqualTo("images/philosophers/rousseau.png"); + } + + @Test + void create_및_update가_imageKey를_저장한다() { + when(philosopherVoiceRepository.existsByName("루소")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse created = philosopherVoiceService.create( + new PhilosopherVoiceRequest("루소", "voice-x", "미호크 장정진", + "images/philosophers/rousseau.png", null)); + assertThat(created.imageKey()).isEqualTo("images/philosophers/rousseau.png"); + + PhilosopherVoice found = entity("루소", "voice-x"); + when(philosopherVoiceRepository.findById(2L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse updated = philosopherVoiceService.update(2L, + new PhilosopherVoiceRequest("루소", "voice-x", null, + "images/philosophers/rousseau-v2.png", null)); + assertThat(updated.imageKey()).isEqualTo("images/philosophers/rousseau-v2.png"); + } + + @Test + void delete_대상이_없으면_예외() { + when(philosopherVoiceRepository.existsById(99L)).thenReturn(false); + + assertThatThrownBy(() -> philosopherVoiceService.delete(99L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + + verify(philosopherVoiceRepository, never()).deleteById(any()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java new file mode 100644 index 00000000..781f0ced --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java @@ -0,0 +1,158 @@ +package com.swyp.picke.domain.scenario.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.scenario.entity.Scenario; +import com.swyp.picke.domain.scenario.entity.ScenarioNode; +import com.swyp.picke.domain.scenario.entity.Script; +import com.swyp.picke.domain.scenario.enums.AudioPathType; +import com.swyp.picke.domain.scenario.enums.CreatorType; +import com.swyp.picke.domain.scenario.enums.ScenarioStatus; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; +import com.swyp.picke.domain.scenario.repository.ScenarioRepository; +import com.swyp.picke.global.infra.media.service.FFmpegService; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import com.swyp.picke.global.infra.tts.service.TtsService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScenarioAudioPipelineServiceTest { + + @Mock + private ScenarioRepository scenarioRepository; + + @Mock + private TtsService ttsService; + + @Mock + private S3UploadService s3UploadService; + + @Mock + private BattleRepository battleRepository; + + private FFmpegService ffmpegService; + private ScenarioAudioPipelineService scenarioAudioPipelineService; + private final List uploadedFiles = new ArrayList<>(); + + @BeforeEach + void setUp() { + ffmpegService = new FFmpegService(); + ReflectionTestUtils.setField(ffmpegService, "ffmpegPath", "/usr/bin/ffmpeg"); + ReflectionTestUtils.setField(ffmpegService, "ffprobePath", "/usr/bin/ffprobe"); + + scenarioAudioPipelineService = new ScenarioAudioPipelineService( + scenarioRepository, + ttsService, + ffmpegService, + s3UploadService, + battleRepository + ); + } + + @AfterEach + void tearDown() { + for (File file : uploadedFiles) { + if (file != null && file.exists()) { + file.delete(); + } + } + } + + @Test + void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() throws Exception { + Battle battle = Battle.builder() + .title("scenario battle") + .summary("summary") + .description("description") + .targetDate(LocalDate.now()) + .audioDuration(0) + .status(BattleStatus.PENDING) + .creatorType(BattleCreatorType.ADMIN) + .build(); + + Scenario scenario = Scenario.builder() + .battle(battle) + .isInteractive(false) + .status(ScenarioStatus.PENDING) + .creatorType(CreatorType.ADMIN) + .build(); + + ScenarioNode node = ScenarioNode.builder() + .nodeName("START") + .isStartNode(true) + .audioDuration(0) + .build(); + + Script firstScript = Script.builder() + .speakerType(SpeakerType.NARRATOR) + .speakerName("Narrator") + .text("First line") + .build(); + Script secondScript = Script.builder() + .speakerType(SpeakerType.NARRATOR) + .speakerName("Narrator") + .text("Second line") + .build(); + + node.addScript(firstScript); + node.addScript(secondScript); + scenario.addNode(node); + scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator")); + + when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario)); + when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) + .thenReturn(ffmpegService.createSilenceFile(1000)); + when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) + .thenReturn(ffmpegService.createSilenceFile(1000)); + + AtomicInteger chunkIndex = new AtomicInteger(); + when(s3UploadService.uploadFile(anyString(), any(File.class))).thenAnswer(invocation -> { + File file = invocation.getArgument(1, File.class); + uploadedFiles.add(file); + + String key = invocation.getArgument(0, String.class); + if (key.contains("/chunks/")) { + return "s3://scenario/chunks/" + chunkIndex.incrementAndGet() + ".mp3"; + } + return "s3://scenario/merged/common.mp3"; + }); + + scenarioAudioPipelineService.generateAndMergeAudioAsync(1L); + + assertThat(scenario.getStatus()).isEqualTo(ScenarioStatus.PUBLISHED); + assertThat(firstScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/1.mp3"); + assertThat(secondScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/2.mp3"); + assertThat(scenario.getAudios()).containsEntry(AudioPathType.COMMON, "s3://scenario/merged/common.mp3"); + assertThat(battle.getAudioDuration()).isNotNull().isGreaterThan(0); + + verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator"), any(Tone.class)); + verify(s3UploadService, times(3)).uploadFile(anyString(), any(File.class)); + verify(battleRepository).save(battle); + verify(scenarioRepository).saveAndFlush(scenario); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java index 2d409aaa..d3687ada 100644 --- a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java @@ -90,8 +90,8 @@ void updateScenarioContent_textChanged_invalidatesOnlyChangedScriptChunk_andClea true, "", List.of( - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-1"), - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-2-new") + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-1", null), + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-2-new", null) ), List.of() ) @@ -138,8 +138,8 @@ void updateScenarioContent_voiceChanged_invalidatesOnlyAffectedSpeakerChunks_and true, "", List.of( - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "same-narrator"), - new ScriptRequest("A", SpeakerType.A, "same-a") + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "same-narrator", null), + new ScriptRequest("A", SpeakerType.A, "same-a", null) ), List.of() ) @@ -184,7 +184,7 @@ void updateScenarioContent_whenPublishedAndModified_triggersAudioPipeline() { "START", true, "", - List.of(new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "new-line")), + List.of(new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "new-line", null)), List.of() ) ), diff --git a/src/test/java/com/swyp/picke/domain/tag/service/TagServiceImplTest.java b/src/test/java/com/swyp/picke/domain/tag/service/TagServiceImplTest.java new file mode 100644 index 00000000..edbfb9d7 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/tag/service/TagServiceImplTest.java @@ -0,0 +1,65 @@ +package com.swyp.picke.domain.tag.service; + +import com.swyp.picke.domain.admin.dto.tag.request.TagRequest; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.BattleTagRepository; +import com.swyp.picke.domain.tag.dto.response.TagListResponse; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TagServiceImplTest { + + @Mock + private TagRepository tagRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleOptionTagRepository battleOptionTagRepository; + @Mock + private BattleRepository battleRepository; + + @InjectMocks + private TagServiceImpl tagService; + + @Test + @DisplayName("철학자 태그 목록에서 지원하지 않는 철학자를 제외한다") + void getTags_filtersUnsupportedPhilosophers() { + Tag supported = Tag.builder().name("소크라테스").type(TagType.PHILOSOPHER).build(); + Tag removed = Tag.builder().name("데카르트").type(TagType.PHILOSOPHER).build(); + when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.PHILOSOPHER)) + .thenReturn(List.of(supported, removed)); + + TagListResponse response = tagService.getTags(TagType.PHILOSOPHER); + + assertThat(response.items()).extracting("name").containsExactly("소크라테스"); + assertThat(response.totalCount()).isEqualTo(1); + } + + @Test + @DisplayName("지원하지 않는 철학자 태그는 생성할 수 없다") + void createTag_rejectsUnsupportedPhilosopher() { + TagRequest request = new TagRequest("데카르트", TagType.PHILOSOPHER); + + assertThatThrownBy(() -> tagService.createTag(request)) + .isInstanceOf(CustomException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.BAD_REQUEST); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/entity/UserTendencyScoreTest.java b/src/test/java/com/swyp/picke/domain/user/entity/UserTendencyScoreTest.java new file mode 100644 index 00000000..0aaf3099 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/entity/UserTendencyScoreTest.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.user.entity; + +import com.swyp.picke.domain.user.enums.ValueAxis; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTendencyScoreTest { + + @Test + @DisplayName("기준 가치관 6개는 각 축에 +1로 반영된다") + void appliesPositiveValueTags() { + UserTendencyScore score = emptyScore(); + + for (String tag : new String[]{"원칙", "이성", "개인", "변화", "내면", "이상"}) { + score.applyValueTag(tag); + } + + assertThat(score.getPrinciple()).isEqualTo(1); + assertThat(score.getReason()).isEqualTo(1); + assertThat(score.getIndividual()).isEqualTo(1); + assertThat(score.getChange()).isEqualTo(1); + assertThat(score.getInner()).isEqualTo(1); + assertThat(score.getIdeal()).isEqualTo(1); + } + + @Test + @DisplayName("반대 가치관 6개는 대응하는 기존 축에 -1로 반영된다") + void appliesNegativeValueTags() { + UserTendencyScore score = emptyScore(); + + for (String tag : new String[]{"결과", "감성", "관계", "전통", "구조", "현실"}) { + score.applyValueTag(tag); + } + + assertThat(score.getPrinciple()).isEqualTo(-1); + assertThat(score.getReason()).isEqualTo(-1); + assertThat(score.getIndividual()).isEqualTo(-1); + assertThat(score.getChange()).isEqualTo(-1); + assertThat(score.getInner()).isEqualTo(-1); + assertThat(score.getIdeal()).isEqualTo(-1); + } + + @Test + @DisplayName("육각형 축은 6개만 유지한다") + void keepsSixAxes() { + assertThat(ValueAxis.values()).hasSize(6); + } + + private UserTendencyScore emptyScore() { + return UserTendencyScore.builder().build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/enums/PhilosopherTypeTest.java b/src/test/java/com/swyp/picke/domain/user/enums/PhilosopherTypeTest.java new file mode 100644 index 00000000..370a9b42 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/enums/PhilosopherTypeTest.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.user.enums; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class PhilosopherTypeTest { + + @Test + @DisplayName("철학자 유형은 지정된 10명만 존재한다") + void containsOnlySupportedPhilosophers() { + Set labels = Arrays.stream(PhilosopherType.values()) + .map(PhilosopherType::getLabel) + .collect(Collectors.toSet()); + + assertThat(labels).containsExactlyInAnyOrder( + "노자", "플라톤", "석가모니", "칸트", "아리스토텔레스", + "마르크스", "소크라테스", "공자", "사르트르", "니체" + ); + assertThat(PhilosopherType.values()).hasSize(10); + } + + @Test + @DisplayName("제거된 철학자 이름은 유형으로 처리하지 않는다") + void doesNotResolveRemovedPhilosophers() { + assertThat(PhilosopherType.fromLabel("데카르트")).isNull(); + assertThat(PhilosopherType.fromLabel("롤스")).isNull(); + assertThat(PhilosopherType.fromLabel("붓다")).isNull(); + assertThat(PhilosopherType.fromLabel("석가모니")).isEqualTo(PhilosopherType.BUDDHA); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java index 5cac8027..db84c0db 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -41,6 +41,12 @@ class CreditServiceTest { @Mock private UserService userService; + @Mock + private com.swyp.picke.domain.notification.service.NotificationService notificationService; + + @Mock + private com.swyp.picke.domain.battle.repository.BattleRepository battleRepository; + @InjectMocks private CreditService creditService; @@ -64,25 +70,25 @@ void addCredit_forCurrentUser_savesDefaultAmount() { User user = newUser(1L, 0); when(userService.findCurrentUser()).thenReturn(user); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount())).thenReturn(1); + when(userRepository.incrementCredit(1L, CreditType.BEST_COMMENT.getDefaultAmount())).thenReturn(1); - creditService.addCredit(CreditType.BATTLE_VOTE, 10L); + creditService.addCredit(CreditType.BEST_COMMENT, 10L); ArgumentCaptor captor = ArgumentCaptor.forClass(CreditHistory.class); verify(creditHistoryRepository).saveAndFlush(captor.capture()); CreditHistory saved = captor.getValue(); assertThat(saved.getUser().getId()).isEqualTo(1L); - assertThat(saved.getCreditType()).isEqualTo(CreditType.BATTLE_VOTE); - assertThat(saved.getAmount()).isEqualTo(CreditType.BATTLE_VOTE.getDefaultAmount()); + assertThat(saved.getCreditType()).isEqualTo(CreditType.BEST_COMMENT); + assertThat(saved.getAmount()).isEqualTo(CreditType.BEST_COMMENT.getDefaultAmount()); assertThat(saved.getReferenceId()).isEqualTo(10L); - verify(userRepository).incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount()); + verify(userRepository).incrementCredit(1L, CreditType.BEST_COMMENT.getDefaultAmount()); } @Test @DisplayName("referenceId가 없으면 적립을 거부한다") void addCredit_withoutReferenceId_throwsException() { - assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, null)) + assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BEST_COMMENT, 10, null)) .isInstanceOf(CustomException.class) .extracting("errorCode") .isEqualTo(ErrorCode.CREDIT_REFERENCE_REQUIRED); @@ -97,12 +103,12 @@ void addCredit_duplicateInsert_ignoresConflict() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); - when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) + when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BEST_COMMENT, 10L)) .thenReturn(true); - creditService.addCredit(1L, CreditType.BATTLE_VOTE, 5, 10L); + creditService.addCredit(1L, CreditType.BEST_COMMENT, 5, 10L); - verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L); + verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BEST_COMMENT, 10L); verify(userRepository, never()).incrementCredit(1L, 5); } @@ -142,10 +148,10 @@ void addCredit_nonDuplicateIntegrityFailure_rethrows() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("broken")); - when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) + when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BEST_COMMENT, 10L)) .thenReturn(false); - assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, 10L)) + assertThatThrownBy(() -> creditService.addCredit(1L, CreditType.BEST_COMMENT, 10, 10L)) .isInstanceOf(CustomException.class) .extracting("errorCode") .isEqualTo(ErrorCode.CREDIT_SAVE_FAILED); diff --git a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java index 7e546280..275a2359 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -52,6 +52,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -166,6 +167,7 @@ void getBattleRecords_returns_paginated_records() { .user(user) .battle(battle) .preVoteOption(optionA) + .postVoteOption(optionA) .build(); ReflectionTestUtils.setField(vote, "id", generateId()); ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); @@ -192,6 +194,7 @@ void getBattleRecords_returns_no_next_when_last_page() { .user(user) .battle(battle) .preVoteOption(optionA) + .postVoteOption(optionA) .build(); ReflectionTestUtils.setField(vote, "id", generateId()); ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); @@ -221,22 +224,25 @@ void getBattleRecords_applies_vote_side_filter() { } @Test - @DisplayName("COMMENT 타입으로 댓글활동을 반환한다") - void getContentActivities_returns_comments() { + @DisplayName("COMMENT 활동은 댓글의 작성자, 좋아요 수, 선택지, 작성 시간을 반환한다") + void getContentActivities_returns_comment_data() { User user = createUser(1L, "tag"); + User perspectiveAuthor = createUser(2L, "perspective-author-tag"); UserProfile profile = createProfile(user, "nick", CharacterType.OWL); Battle battle = createBattle("배틀"); Long battleId = battle.getId(); BattleOption option = createOption(battle, BattleOptionLabel.A); Long optionId = option.getId(); + BattleOption commentOption = createOption(battle, BattleOptionLabel.B); Perspective perspective = Perspective.builder() .battle(battle) - .user(user) + .user(perspectiveAuthor) .option(option) .content("관점 내용") .build(); ReflectionTestUtils.setField(perspective, "id", generateId()); + ReflectionTestUtils.setField(perspective, "likeCount", 7); PerspectiveComment comment = PerspectiveComment.builder() .perspective(perspective) @@ -244,14 +250,20 @@ void getContentActivities_returns_comments() { .content("댓글") .build(); ReflectionTestUtils.setField(comment, "id", generateId()); - ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(comment, "likeCount", 2); + LocalDateTime commentCreatedAt = LocalDateTime.now(); + ReflectionTestUtils.setField(comment, "createdAt", commentCreatedAt); when(userService.findCurrentUser()).thenReturn(user); when(userService.findUserProfile(1L)).thenReturn(profile); - when(perspectiveQueryService.findUserComments(1L, 0, 20)).thenReturn(List.of(comment)); + when(perspectiveQueryService.findUserComments(1L, 20)).thenReturn(List.of(comment)); + when(perspectiveQueryService.findUserPerspectives(1L, 20)).thenReturn(List.of()); when(perspectiveQueryService.countUserComments(1L)).thenReturn(1L); + when(perspectiveQueryService.countUserPerspectives(1L)).thenReturn(0L); when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(voteQueryService.findPostVoteOptionsByBattleIds(1L, List.of(battleId))) + .thenReturn(Map.of(battleId, commentOption)); when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.COMMENT); @@ -259,6 +271,68 @@ void getContentActivities_returns_comments() { assertThat(response.items()).hasSize(1); assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.COMMENT); assertThat(response.items().get(0).content()).isEqualTo("댓글"); + assertThat(response.items().get(0).author().userTag()).isEqualTo("tag"); + assertThat(response.items().get(0).author().nickname()).isEqualTo("nick"); + assertThat(response.items().get(0).likeCount()).isEqualTo(2); + assertThat(response.items().get(0).voteSide()).isEqualTo(VoteSide.CON); + assertThat(response.items().get(0).optionTitle()).isEqualTo("B"); + assertThat(response.items().get(0).createdAt()).isEqualTo(commentCreatedAt); + verify(userService).findSummaryById(1L); + verify(userService, never()).findSummaryById(2L); + } + + @Test + @DisplayName("COMMENT 타입으로 조회하면 내가 작성한 관점과 댓글이 최신순으로 병합되어 반환된다") + void getContentActivities_merges_perspectives_and_comments_by_created_at() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + Battle battle = createBattle("배틀"); + Long battleId = battle.getId(); + BattleOption option = createOption(battle, BattleOptionLabel.A); + Long optionId = option.getId(); + + Perspective myPerspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("내가 쓴 관점") + .build(); + ReflectionTestUtils.setField(myPerspective, "id", generateId()); + ReflectionTestUtils.setField(myPerspective, "createdAt", LocalDateTime.now()); + + Perspective othersPerspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("다른 사람 관점") + .build(); + ReflectionTestUtils.setField(othersPerspective, "id", generateId()); + + PerspectiveComment comment = PerspectiveComment.builder() + .perspective(othersPerspective) + .user(user) + .content("내가 남긴 댓글") + .build(); + ReflectionTestUtils.setField(comment, "id", generateId()); + ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now().minusMinutes(1)); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + when(perspectiveQueryService.findUserComments(1L, 20)).thenReturn(List.of(comment)); + when(perspectiveQueryService.findUserPerspectives(1L, 20)).thenReturn(List.of(myPerspective)); + when(perspectiveQueryService.countUserComments(1L)).thenReturn(1L); + when(perspectiveQueryService.countUserPerspectives(1L)).thenReturn(1L); + when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); + when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); + + ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.COMMENT); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.PERSPECTIVE); + assertThat(response.items().get(0).content()).isEqualTo("내가 쓴 관점"); + assertThat(response.items().get(1).activityType()).isEqualTo(ActivityType.COMMENT); + assertThat(response.items().get(1).content()).isEqualTo("내가 남긴 댓글"); } @Test @@ -303,7 +377,7 @@ void getContentActivities_returns_likes() { void getCreditHistory_returns_paginated_history() { User user = createUser(1L, "tag"); CreditHistory latest = creditHistory(301L, user, CreditType.BEST_COMMENT, 50, 91L, LocalDateTime.now()); - CreditHistory older = creditHistory(300L, user, CreditType.BATTLE_VOTE, 5, 90L, LocalDateTime.now().minusDays(1)); + CreditHistory older = creditHistory(300L, user, CreditType.MAJORITY_WIN, 5, 90L, LocalDateTime.now().minusDays(1)); when(userService.findCurrentUser()).thenReturn(user); when(creditService.getHistory(1L, PageRequest.of(0, 2))) diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java deleted file mode 100644 index 0b675986..00000000 --- a/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.swyp.picke.domain.user.service.batch; - -import com.swyp.picke.domain.user.entity.User; -import com.swyp.picke.domain.user.enums.CreditType; -import com.swyp.picke.domain.user.enums.UserRole; -import com.swyp.picke.domain.user.enums.UserStatus; -import com.swyp.picke.domain.user.repository.UserRepository; -import com.swyp.picke.domain.user.service.CreditService; -import java.time.LocalDate; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class WeeklyChargeJobTest { - - @Mock - private UserRepository userRepository; - - @Mock - private CreditService creditService; - - @InjectMocks - private WeeklyChargeJob job; - - @Test - @DisplayName("활성 사용자에게만 WEEKLY_CHARGE 를 지급한다") - void run_rewardsOnlyActiveUsers() { - User activeUser1 = user(1L); - User activeUser2 = user(2L); - LocalDate runDate = LocalDate.of(2026, 4, 13); - - when(userRepository.findAllByStatus(UserStatus.ACTIVE)).thenReturn(List.of(activeUser1, activeUser2)); - - job.run(runDate); - - verify(creditService).addCredit(1L, CreditType.WEEKLY_CHARGE, 20260413L); - verify(creditService).addCredit(2L, CreditType.WEEKLY_CHARGE, 20260413L); - } - - private User user(Long id) { - User user = User.builder() - .userTag("user-" + id) - .nickname("nick") - .role(UserRole.USER) - .status(UserStatus.ACTIVE) - .build(); - ReflectionTestUtils.setField(user, "id", id); - return user; - } -} diff --git a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java index 6135445a..e876aef6 100644 --- a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java @@ -5,14 +5,20 @@ import com.swyp.picke.domain.battle.enums.BattleOptionLabel; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.entity.BattleOptionTag; import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.entity.UserTendencyScore; import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.enums.UserRole; import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.repository.UserTendencyScoreRepository; import com.swyp.picke.domain.user.service.CreditService; import com.swyp.picke.domain.user.service.UserBattleService; import com.swyp.picke.domain.vote.dto.request.VoteRequest; @@ -20,6 +26,8 @@ import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -47,9 +55,15 @@ class BattleVoteServiceImplTest { @Mock private BattleOptionRepository battleOptionRepository; + @Mock + private BattleOptionTagRepository battleOptionTagRepository; + @Mock private UserRepository userRepository; + @Mock + private UserTendencyScoreRepository userTendencyScoreRepository; + @Mock private UserBattleService userBattleService; @@ -62,10 +76,20 @@ class BattleVoteServiceImplTest { @InjectMocks private BattleVoteServiceImpl battleVoteService; + /** + * BattleVoteServiceImpl은 "오늘"을 KST로 판단한다(LocalDate.now(KST)). + * 테스트가 시스템 기본 시간대를 쓰면 UTC 러너에서 15시 이후로 하루가 어긋나 실패한다. + */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static LocalDate today() { + return LocalDate.now(KST); + } + @Test @DisplayName("오늘 배틀이 아니면 최초 사전 투표 시 BATTLE_ENTRY 크레딧을 차감한다") void preVote_chargesBattleEntryCreditForPastBattle() { - Battle battle = battle(100L, LocalDate.now().minusDays(1)); + Battle battle = battle(100L, today().minusDays(1)); User user = user(10L); BattleOption option = option(201L, battle, BattleOptionLabel.A); @@ -86,7 +110,7 @@ void preVote_chargesBattleEntryCreditForPastBattle() { @Test @DisplayName("오늘 배틀이면 최초 사전 투표 시 크레딧을 차감하지 않는다") void preVote_doesNotChargeBattleEntryCreditForTodayBattle() { - Battle battle = battle(100L, LocalDate.now()); + Battle battle = battle(100L, today()); User user = user(10L); BattleOption option = option(201L, battle, BattleOptionLabel.A); @@ -103,10 +127,31 @@ void preVote_doesNotChargeBattleEntryCreditForTodayBattle() { verify(creditService, never()).addCredit(10L, CreditType.BATTLE_ENTRY, 100L); } + @Test + @DisplayName("오늘의 배틀이라도 오늘 이미 무료 진입을 사용했다면 크레딧을 차감한다") + void preVote_chargesBattleEntryCreditWhenFreeEntryAlreadyUsedToday() { + Battle battle = battle(100L, today()); + User user = user(10L); + BattleOption option = option(201L, battle, BattleOptionLabel.A); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(201L)).thenReturn(Optional.of(option)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.empty()); + when(battleVoteRepository.existsByUserIdAndBattle_TargetDate(10L, today())).thenReturn(true); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.NONE)); + + VoteResultResponse response = battleVoteService.preVote(100L, 10L, new VoteRequest(201L)); + + assertThat(response.status()).isEqualTo(UserBattleStep.PRE_VOTE); + verify(creditService).addCredit(10L, CreditType.BATTLE_ENTRY, 100L); + } + @Test @DisplayName("이미 사전 투표한 배틀이면 옵션 변경 시 추가 차감하지 않는다") void preVote_doesNotChargeAgainWhenVoteAlreadyExists() { - Battle battle = battle(100L, LocalDate.now().minusDays(1)); + Battle battle = battle(100L, today().minusDays(1)); User user = user(10L); BattleOption oldOption = option(200L, battle, BattleOptionLabel.B); BattleOption newOption = option(201L, battle, BattleOptionLabel.A); @@ -131,8 +176,8 @@ void preVote_doesNotChargeAgainWhenVoteAlreadyExists() { } @Test - @DisplayName("사후 투표 완료 시 참여 보상 크레딧을 지급한다") - void postVote_rewardsBattleParticipationCredit() { + @DisplayName("사후 투표 완료 시 스텝을 완료 상태로 전환한다") + void postVote_completesUserBattleStep() { Battle battle = battle(100L, null); User user = user(10L); BattleOption preOption = option(201L, battle, BattleOptionLabel.A); @@ -157,7 +202,76 @@ void postVote_rewardsBattleParticipationCredit() { assertThat(response.voteId()).isEqualTo(300L); assertThat(response.status()).isEqualTo(UserBattleStep.COMPLETED); verify(userBattleService).upsertStep(user, battle, UserBattleStep.COMPLETED); - verify(creditService).addCredit(10L, CreditType.BATTLE_VOTE, 300L); + } + + @Test + @DisplayName("대표 6개에 없는 반대 가치관도 실제 사후투표 입력 시 -로 계산한다") + void postVote_appliesNegativeValueTag() { + Battle battle = battle(100L, null); + User user = user(10L); + BattleOption option = option(202L, battle, BattleOptionLabel.B); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(option) + .build(); + UserTendencyScore score = UserTendencyScore.builder().user(user).build(); + Tag structure = Tag.builder().name("구조").type(TagType.VALUE).build(); + BattleOptionTag optionTag = BattleOptionTag.builder() + .battleOption(option) + .tag(structure) + .build(); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(202L)).thenReturn(Optional.of(option)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.of(vote)); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.POST_VOTE)); + when(userTendencyScoreRepository.findByUserId(10L)).thenReturn(Optional.of(score)); + when(battleOptionTagRepository.findByBattleOption(option)).thenReturn(List.of(optionTag)); + + battleVoteService.postVote(100L, 10L, new VoteRequest(202L)); + + assertThat(score.getInner()).isEqualTo(-1); + } + + @Test + @DisplayName("사후투표를 변경하면 기존 태그 점수를 제거하고 새 태그를 반영한다") + void postVote_replacesPreviousValueTagScore() { + Battle battle = battle(100L, null); + User user = user(10L); + BattleOption previousOption = option(201L, battle, BattleOptionLabel.A); + BattleOption newOption = option(202L, battle, BattleOptionLabel.B); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(previousOption) + .postVoteOption(previousOption) + .build(); + UserTendencyScore score = UserTendencyScore.builder().user(user).inner(1).build(); + BattleOptionTag innerTag = BattleOptionTag.builder() + .battleOption(previousOption) + .tag(Tag.builder().name("내면").type(TagType.VALUE).build()) + .build(); + BattleOptionTag structureTag = BattleOptionTag.builder() + .battleOption(newOption) + .tag(Tag.builder().name("구조").type(TagType.VALUE).build()) + .build(); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(202L)).thenReturn(Optional.of(newOption)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.of(vote)); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.COMPLETED)); + when(userTendencyScoreRepository.findByUserId(10L)).thenReturn(Optional.of(score)); + when(battleOptionTagRepository.findByBattleOption(previousOption)).thenReturn(List.of(innerTag)); + when(battleOptionTagRepository.findByBattleOption(newOption)).thenReturn(List.of(structureTag)); + + battleVoteService.postVote(100L, 10L, new VoteRequest(202L)); + + assertThat(score.getInner()).isEqualTo(-1); } private Battle battle(Long id, LocalDate targetDate) { diff --git a/src/test/java/com/swyp/picke/domain/vote/service/VoteQueryServiceTest.java b/src/test/java/com/swyp/picke/domain/vote/service/VoteQueryServiceTest.java new file mode 100644 index 00000000..763e5c75 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/vote/service/VoteQueryServiceTest.java @@ -0,0 +1,106 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.user.enums.VoteSide; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class VoteQueryServiceTest { + + @Mock + private BattleVoteRepository battleVoteRepository; + + @InjectMocks + private VoteQueryService voteQueryService; + + @Test + @DisplayName("voteSide 없이 조회하면 사후투표를 완료한 기록만 조회한다") + void findUserVotes_without_voteSide_uses_post_vote_completed_query() { + when(battleVoteRepository.findByUserIdAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), any())) + .thenReturn(List.of()); + + voteQueryService.findUserVotes(1L, 0, 20, null); + + verify(battleVoteRepository).findByUserIdAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), any()); + } + + @Test + @DisplayName("voteSide=PRO로 조회하면 사후투표를 완료한 PRO 기록만 조회한다") + void findUserVotes_with_pro_uses_post_vote_completed_query() { + when(battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), eq(1), any())) + .thenReturn(List.of()); + + voteQueryService.findUserVotes(1L, 0, 20, VoteSide.PRO); + + verify(battleVoteRepository).findByUserIdAndPreVoteOptionDisplayOrderAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), eq(1), any()); + } + + @Test + @DisplayName("voteSide=CON으로 조회하면 사후투표를 완료한 CON 기록만 조회한다") + void findUserVotes_with_con_uses_post_vote_completed_query() { + when(battleVoteRepository.findByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), eq(1), any())) + .thenReturn(List.of()); + + voteQueryService.findUserVotes(1L, 0, 20, VoteSide.CON); + + verify(battleVoteRepository).findByUserIdAndPreVoteOptionDisplayOrderNotAndPostVoteOptionIsNotNullOrderByCreatedAtDesc(eq(1L), eq(1), any()); + } + + @Test + @DisplayName("voteSide 없이 카운트하면 사후투표를 완료한 기록만 센다") + void countUserVotes_without_voteSide_uses_post_vote_completed_query() { + when(battleVoteRepository.countByUserIdAndPostVoteOptionIsNotNull(1L)).thenReturn(3L); + + long count = voteQueryService.countUserVotes(1L, null); + + assertThat(count).isEqualTo(3L); + verify(battleVoteRepository).countByUserIdAndPostVoteOptionIsNotNull(1L); + } + + @Test + @DisplayName("총 참여 횟수(countTotalParticipation)는 사전투표만 한 기록도 포함한다") + void countTotalParticipation_includes_pre_vote_only_records() { + when(battleVoteRepository.countByUserId(1L)).thenReturn(5L); + + long count = voteQueryService.countTotalParticipation(1L); + + assertThat(count).isEqualTo(5L); + verify(battleVoteRepository).countByUserId(1L); + } + + @Test + @DisplayName("배틀별 사후 투표 선택지를 조회한다") + void findPostVoteOptionsByBattleIds_returns_post_vote_options() { + Battle battle = mock(Battle.class); + BattleOption postVoteOption = mock(BattleOption.class); + BattleVote vote = mock(BattleVote.class); + + when(battle.getId()).thenReturn(10L); + when(vote.getBattle()).thenReturn(battle); + when(vote.getPostVoteOption()).thenReturn(postVoteOption); + when(battleVoteRepository.findByUserIdAndBattleIdInWithPostVoteOption(1L, List.of(10L))) + .thenReturn(List.of(vote)); + + Map result = voteQueryService.findPostVoteOptionsByBattleIds(1L, List.of(10L)); + + assertThat(result).containsEntry(10L, postVoteOption); + } +} diff --git a/src/test/java/com/swyp/picke/global/config/AdPropertyDefaultTest.java b/src/test/java/com/swyp/picke/global/config/AdPropertyDefaultTest.java new file mode 100644 index 00000000..6d43f335 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/config/AdPropertyDefaultTest.java @@ -0,0 +1,46 @@ +package com.swyp.picke.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 광고 환경변수에 기본값이 없으면 값이 빠진 서버에서 앱 전체가 뜨지 않는다. + * 광고와 무관한 API까지 같이 죽는다. 실제로 COUPANG_PARTNERS_ID 때문에 dev가 502로 내려간 적이 있다. + * + *

테스트 설정(application-test.yml)이 값을 박아 두기 때문에 컨텍스트를 띄우는 것만으로는 + * 이 경우를 재현할 수 없다. 그래서 설정 파일의 플레이스홀더 자체를 검사한다. + */ +class AdPropertyDefaultTest { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{(COUPANG_[A-Z0-9_]+|ADPICK_[A-Z0-9_]+)(:[^}]*)?}"); + + @Test + @DisplayName("광고 환경변수는 모두 기본값을 갖는다. 값이 없어도 앱은 떠야 한다") + void adPlaceholders_haveDefaults() throws IOException { + String yml = new String(new ClassPathResource("application.yml").getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + + Matcher matcher = PLACEHOLDER.matcher(yml); + List missingDefaults = new ArrayList<>(); + int found = 0; + while (matcher.find()) { + found++; + if (matcher.group(2) == null) { + missingDefaults.add(matcher.group(1)); + } + } + + assertThat(found).isPositive(); + assertThat(missingDefaults).isEmpty(); + } +} diff --git a/src/test/java/com/swyp/picke/global/config/SwaggerAdGroupTest.java b/src/test/java/com/swyp/picke/global/config/SwaggerAdGroupTest.java new file mode 100644 index 00000000..d83769af --- /dev/null +++ b/src/test/java/com/swyp/picke/global/config/SwaggerAdGroupTest.java @@ -0,0 +1,75 @@ +package com.swyp.picke.global.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 사용자 그룹은 FE_USED_OPERATIONS 화이트리스트로, 관리자 그룹은 pathsToExclude 로 광고를 걷어낸다. + * 둘 중 하나만 손대도 광고 탭이 조용히 비어 버리므로 그룹 구성 자체를 고정해 둔다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SwaggerAdGroupTest { + + private static final String AD_GROUP = "3. 광고 API"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("광고 API는 스웨거 그룹 선택 목록에 별도 탭으로 나온다") + void swaggerConfig_exposesAdGroup() throws Exception { + mockMvc.perform(get("/v3/api-docs/swagger-config")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.urls[?(@.name == '" + AD_GROUP + "')]").exists()); + } + + @Test + @DisplayName("광고 탭은 문서를 연 주소로 요청한다. 운영에서 열면 운영으로, dev에서 열면 dev로 나간다") + void adGroup_defaultsToCurrentOrigin() throws Exception { + mockMvc.perform(get("/v3/api-docs/" + AD_GROUP)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.servers[0].url").value("/")) + .andExpect(jsonPath("$.servers[*].url").value(hasItem("https://dev.picke.store"))) + .andExpect(jsonPath("$.servers[*].url").value(hasItem("https://picke.store"))); + } + + @Test + @DisplayName("광고 탭에서 운영 광고 도메인을 고를 수 있다. 운영 광고는 별도 서비스라 API 서버로는 조회되지 않는다") + void adGroup_offersAdDomain() throws Exception { + mockMvc.perform(get("/v3/api-docs/" + AD_GROUP)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.servers[1].url").value("https://ad.picke.store")); + } + + @Test + @DisplayName("다른 탭의 서버 순서는 그대로 둔다") + void otherGroups_keepProdFirst() throws Exception { + mockMvc.perform(get("/v3/api-docs/0. 모든 API")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.servers[0].url").value("https://picke.store")); + } + + @Test + @DisplayName("광고 탭에는 앱용 조회·집계와 관리자용 소재 관리가 함께 묶인다") + void adGroup_containsAppAndAdminOperations() throws Exception { + mockMvc.perform(get("/v3/api-docs/" + AD_GROUP)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paths['/api/v1/ads'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/ads/impressions'].post").exists()) + .andExpect(jsonPath("$.paths['/api/v1/admin/ads'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/admin/ads/stats'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/admin/ads/clicks'].get").exists()); + } +} diff --git a/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java b/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java new file mode 100644 index 00000000..9fadb845 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/fcm/service/FcmPushServiceTest.java @@ -0,0 +1,97 @@ +package com.swyp.picke.global.infra.fcm.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.swyp.picke.domain.notification.entity.UserDevice; +import com.swyp.picke.domain.notification.enums.DevicePlatform; +import com.swyp.picke.domain.notification.repository.UserDeviceRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FcmPushServiceTest { + + @Mock + private FirebaseMessaging firebaseMessaging; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Test + void send_includes_title_and_body_in_data_payload() throws Exception { + FcmPushService fcmPushService = new FcmPushService(firebaseMessaging, userDeviceRepository); + UserDevice device = buildDevice(); + + fcmPushService.send(device, "제목", "본문", Map.of("type", "TEST")); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(firebaseMessaging).send(messageCaptor.capture()); + + Map data = extractData(messageCaptor.getValue()); + assertThat(data).containsEntry("title", "제목"); + assertThat(data).containsEntry("body", "본문"); + assertThat(data).containsEntry("type", "TEST"); + } + + @Test + void send_deletes_device_when_token_is_unregistered() throws Exception { + FcmPushService fcmPushService = new FcmPushService(firebaseMessaging, userDeviceRepository); + UserDevice device = buildDevice(); + + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + lenient().when(exception.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED); + doThrow(exception).when(firebaseMessaging).send(any(Message.class)); + + fcmPushService.send(device, "제목", "본문", Map.of("type", "TEST")); + + verify(userDeviceRepository).deleteById(device.getId()); + } + + @Test + void send_keeps_device_when_error_is_not_unregistered() throws Exception { + FcmPushService fcmPushService = new FcmPushService(firebaseMessaging, userDeviceRepository); + UserDevice device = buildDevice(); + + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + lenient().when(exception.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNAVAILABLE); + doThrow(exception).when(firebaseMessaging).send(any(Message.class)); + + fcmPushService.send(device, "제목", "본문", Map.of("type", "TEST")); + + verify(userDeviceRepository, never()).deleteById(any()); + } + + @SuppressWarnings("unchecked") + private Map extractData(Message message) throws Exception { + Field dataField = Message.class.getDeclaredField("data"); + dataField.setAccessible(true); + return (Map) dataField.get(message); + } + + private UserDevice buildDevice() throws Exception { + UserDevice device = UserDevice.builder() + .fcmToken("token") + .platform(DevicePlatform.ANDROID) + .build(); + Field idField = device.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(device, 1L); + return device; + } +} diff --git a/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java new file mode 100644 index 00000000..6827c011 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java @@ -0,0 +1,83 @@ +package com.swyp.picke.global.infra.local.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +class LocalDraftFileStorageServiceTest { + + private LocalDraftFileStorageService service; + private S3UploadService s3UploadService; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + service = new LocalDraftFileStorageService(); + ReflectionTestUtils.setField(service, "localStorageRoot", tempDir.toString()); + ReflectionTestUtils.setField(service, "baseUrl", "https://dev.picke.store"); + s3UploadService = mock(S3UploadService.class); + } + + private String saveDraft() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "rousseau.png", "image/png", new byte[]{1, 2, 3}); + return service.saveDraftFile(file); + } + + @Test + void resolveS3Key는_실제_업로드_없이_목적지_key만_계산한다() throws IOException { + String localKey = saveDraft(); + + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + + assertThat(s3Key).startsWith("images/philosophers/").endsWith("rousseau.png"); + // 부수효과 없음: 로컬 파일 그대로 남아있고 S3 호출 없음 + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } + + @Test + void resolveS3Key는_draft_파일이_없으면_예외를_던진다() { + assertThatThrownBy(() -> service.resolveS3Key("local/drafts/missing_file.png", FileCategory.PHILOSOPHER)) + .isInstanceOf(CustomException.class); + } + + @Test + void promoteToS3는_업로드하고_로컬_draft를_삭제한다() throws IOException { + String localKey = saveDraft(); + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + when(s3UploadService.uploadFile(eq(s3Key), any())) + .thenReturn(s3Key); + + service.promoteToS3(localKey, s3Key, s3UploadService); + + verify(s3UploadService).uploadFile(eq(s3Key), any()); + assertThat(service.isLocalDraftReference(localKey)).isTrue(); // 여전히 local draft 형식의 키지만 + // 실제 파일은 삭제됐어야 한다 -> 다시 promote 시도해도 재업로드 없이 조용히 스킵 + service.promoteToS3(localKey, s3Key, s3UploadService); + verify(s3UploadService, times(1)) + .uploadFile(eq(s3Key), any()); + } + + @Test + void promoteToS3는_local_draft가_아니면_아무일도_하지_않는다() { + service.promoteToS3("images/philosophers/already-s3.png", "images/philosophers/already-s3.png", s3UploadService); + + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } +} diff --git a/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java new file mode 100644 index 00000000..d983c433 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.global.infra.tts.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.scenario.enums.Tone; +import org.junit.jupiter.api.Test; + +class TtsTextNormalizerTest { + + @Test + void NEUTRAL_톤은_태그를_붙이지_않는다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그건 궤변이다."); + } + + @Test + void 톤이_있으면_Fish_인라인_표기를_맨_앞에_붙인다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.ANGRY); + + assertThat(result).isEqualTo("[angry] 그건 궤변이다."); + } + + @Test + void SOFT_TONE은_soft_tone_표기로_붙는다() { + String result = TtsTextNormalizer.normalize("괜찮아요.", Tone.SOFT_TONE); + + assertThat(result).isEqualTo("[soft tone] 괜찮아요."); + } + + @Test + void 관용_효과_표기를_Fish_정식_표기로_정규화한다() { + String result = TtsTextNormalizer.normalize( + "그래서 [pause] 나는 [soft] 포기했다 [long pause] 결국.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그래서 [break] 나는 [soft tone] 포기했다 [long-break] 결국."); + } + + @Test + void 효과_정규화는_대소문자를_무시한다() { + String result = TtsTextNormalizer.normalize("음 [PAUSE] 그리고 [Long Pause] 끝.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("음 [break] 그리고 [long-break] 끝."); + } + + @Test + void 문서에_없는_효과_태그는_그대로_둔다() { + String result = TtsTextNormalizer.normalize("[sighing] 나는 지쳤다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("[sighing] 나는 지쳤다."); + } + + @Test + void 톤과_효과가_함께_있으면_톤은_앞_효과는_원위치() { + String result = TtsTextNormalizer.normalize("나는 [sighing] 포기했다.", Tone.SAD); + + assertThat(result).isEqualTo("[sad] 나는 [sighing] 포기했다."); + } + + @Test + void null_텍스트도_톤_태그만_남기고_안전하게_처리한다() { + String result = TtsTextNormalizer.normalize(null, Tone.EXCITED); + + assertThat(result).isEqualTo("[excited]"); + } +} diff --git a/src/test/java/com/swyp/picke/global/web/controller/StaticTextFileControllerTest.java b/src/test/java/com/swyp/picke/global/web/controller/StaticTextFileControllerTest.java new file mode 100644 index 00000000..a9a2e854 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/web/controller/StaticTextFileControllerTest.java @@ -0,0 +1,28 @@ +package com.swyp.picke.global.web.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class StaticTextFileControllerTest { + + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new StaticTextFileController()).build(); + + @Test + void appAdsTxt_isServedAsTextPlain() throws Exception { + mockMvc.perform(get("/app-ads.txt")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("text/plain")); + } + + @Test + void robotsTxt_isServedAsTextPlain() throws Exception { + mockMvc.perform(get("/robots.txt")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("text/plain")); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8a951d1f..cd11c2a0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -25,6 +25,17 @@ spring: credentials: location: file:/tmp/dummy.json +firebase: + credentials: + location: src/test/resources/firebase-test-credentials.json + +admob: + app-id: dummy + reward: + unit-id: + ios: dummy + android: dummy + oauth: kakao: client-id: dummy @@ -32,6 +43,11 @@ oauth: google: client-id: dummy client-secret: dummy + apple: + team-id: dummy + client-id: dummy + key-id: dummy + private-key: dummy openai: api-key: dummy @@ -78,3 +94,7 @@ media: path: ffmpeg ffprobe: path: ffprobe + +coupang: + partners: + id: AF0000000 diff --git a/src/test/resources/battle-scripts/sample-interactive.txt b/src/test/resources/battle-scripts/sample-interactive.txt new file mode 100644 index 00000000..6f6760ed --- /dev/null +++ b/src/test/resources/battle-scripts/sample-interactive.txt @@ -0,0 +1,60 @@ +분기 O 버전 011 — AI 판사는 도입해야 하는가? +오프닝 +판사도 인간입니다. 배가 고프면 더 가혹해진다는 연구가 있습니다. AI가 판사를 대신한다면 어떨까요? + +칸트: 정의는 보편적 원칙 위에 서야 합니다. AI는 동일한 사안에 동일한 기준을 적용합니다. + +아리스토텔레스: 정의는 원칙만으로 완성되지 않습니다. 실천적 지혜가 필요합니다. + +칸트는 말합니다. 감정과 편견 없는 일관된 원칙이야말로 진정한 공정함이라고. 당신이 재판을 받아야 한다면, 어느 쪽 판사 앞에 서고 싶습니까? + +메타데이터 +구분 +상세 내용 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +AI 판사를 도입해야 한다 +철학자 키워드 +#칸트, #플라톤, #소크라테스 +성향 지표 (Value) +[원칙], [이성] +B안 선택 시 +선택지 명칭 +AI 판사는 도입해선 안 된다 +철학자 키워드 +#아리스토텔레스, #공자, #사르트르 +성향 지표 (Value) +[개인], [변화] + +(분기 버전) +[오프닝] +판사도 인간입니다. 배가 고프면 더 가혹해진다는 연구가 있습니다. AI가 판사를 대신한다면 어떨까요? + +[1라운드] +칸트: 정의는 보편적 원칙 위에 서야 합니다. AI는 동일한 사안에 동일한 기준을 적용합니다. + +아리스토텔레스: 정의는 원칙만으로 완성되지 않습니다. 실천적 지혜가 필요합니다. + +[2라운드] +아리스토텔레스: AI는 과거 데이터로 학습합니다. 그 데이터 안에 이미 인간의 편견이 담겨 있습니다. + +칸트: 그건 데이터의 문제이지 AI 자체의 문제가 아닙니다. + +👉 [선택의 시간] +A: AI 판사를 도입해야 한다. 일관된 원칙이 진정한 공정함이다. (칸트) +B: AI 판사는 도입해선 안 된다. 맥락을 이해하는 인간의 판단이 필요하다. (아리스토텔레스) + +[분기 A — 도입을 선택했을 때] +칸트: 당신은 공정함의 본질을 파악했습니다. AI 판사는 모든 판결 과정을 투명하게 공개할 수 있습니다. + +아리스토텔레스: 재판은 단순한 계산이 아닙니다. 그 인간적인 무게를 감당하면서 판결을 내리는 것. + +[분기 B — 반대를 선택했을 때] +아리스토텔레스: 당신은 정의의 본질을 이해하고 있습니다. 법은 규칙이지만, 재판은 인간에 관한 것입니다. + +칸트: 그 마음은 이해합니다. 하지만 인간 판사의 편견으로 억울하게 처벌받은 사람들을 생각해보십시오. + +[클로징] +칸트는 말합니다. 감정과 편견 없는 일관된 원칙이야말로 진정한 공정함이라고. 당신이 재판을 받아야 한다면, 어느 쪽 판사 앞에 서고 싶습니까? diff --git a/src/test/resources/battle-scripts/sample-linear.txt b/src/test/resources/battle-scripts/sample-linear.txt new file mode 100644 index 00000000..e7247969 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-linear.txt @@ -0,0 +1,39 @@ +분기 X 버전 001 — 인간은 본래 선한가, 악한가? +오프닝 +재난이 일어났을 때 사람들은 서로를 돕습니다. 재난이 길어지면 서로를 착취합니다. 어느 쪽이 인간의 본 모습일까요? + +루소: 인간은 자연 상태에서 평화로웠습니다. 경쟁도, 소유욕도, 증오도 — 이건 문명이 만든 겁니다. 아이를 보십시오. 태어난 아이는 나누고 웃고, 타인의 고통에 울음으로 반응합니다. 악은 가르쳐지는 것입니다. + +홉스: 역사상 국가 없이 평화로웠던 사회는 없습니다. 자연 상태의 인간의 삶은 고독하고 가난하고 불결하고 잔인하고 짧습니다. 선사 시대 유골에는 폭력의 흔적이 가득합니다. 문명은 타락이 아니라 구원입니다. + +루소: 그 폭력은 소유가 생긴 이후의 것입니다. 최초의 악은 '이건 내 것이다'라고 말한 순간 시작됐습니다. + +홉스: 소유욕은 어디서 왔습니까. 문명 이전에도 영역 싸움은 있었습니다. 침팬지도 영역 싸움을 합니다. 선함은 선택이고 교육이지, 기본값이 아닙니다. + +루소: 왜 인간은 아무 이익 없이 낯선 이를 돕습니까. 이익 계산으로는 설명되지 않는 선함이 존재합니다. + +홉스: 그런 선함은 감탄스럽지만 예외입니다. 시스템은 예외가 아닌 평균으로 설계해야 합니다. 인간 대부분이 선하다고 가정한 사회는 소수의 악한 자에게 무너집니다. 법과 제도는 인간의 선함을 믿어서가 아니라, 믿지 않기 때문에 만들어진 것입니다. + +루소는 말합니다. 악은 가르쳐지는 것이고, 인간의 기본값은 선이라고. 홉스는 말합니다. 선함은 제도가 만들어내는 것이고, 그것 없인 인간은 서로를 파괴한다고. 당신은 인간을 믿습니까? + +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +선하다 +철학자 키워드 +#노자, #플라톤, #석가모니 +성향 지표 (Value) +[내면], [이상] +B안 선택 시 +선택지 명칭 +악하다 +철학자 키워드 +#칸트, #아리스토텔레스, #마르크스 +성향 지표 (Value) +[원칙], [이성] diff --git a/src/test/resources/battle-scripts/sample-real-1.txt b/src/test/resources/battle-scripts/sample-real-1.txt new file mode 100644 index 00000000..15c47ed4 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-1.txt @@ -0,0 +1,56 @@ +008 — 첫 데이트 국밥, 무죄인가 유죄인가? + +사전 투표 +"유죄다 (플라톤)" vs "무죄다 (마르크스)" + +[오프닝] +설레는 첫 데이트, 무슨 옷을 입을지 고민한 끝에 마주 앉은 자리. 그런데 눈앞에 뚝배기와 깍두기가 놓였습니다. "첫 만남엔 무조건 분위기 좋은 곳"이라는 격조파와 "처음부터 내숭 없이 든든하게"라는 실속파의 대결. 인생의 가장 아름다운 이상을 추구하는 플라톤과 현실의 실리를 날카롭게 따지는 마르크스가 보글보글 끓는 국밥 앞에서 마주 앉았습니다. + +1라운드: "아름다운 첫인상" vs "실속 있는 노동의 대가" +플라톤 (유죄): "첫 데이트에서 국밥은 명백한 '유죄'입니다! 첫 만남이란 서로에게 가장 아름다운 모습을 보여주고, 설레는 감정을 정성스럽게 가꾸어가는 신성한 시간입니다. 땀을 뻘뻘 흘리며 마늘과 다대기를 섞는 모습은, 첫 데이트가 가져야 할 최소한의 낭만과 격조를 깨뜨리는 파괴 행위입니다." +마르크스 (무죄): "플라톤 선생, 낭만이라는 허울로 자본주의의 사치를 정당화하지 마십시오! 피땀 흘려 번 돈으로 먹는 첫 식사입니다. 거품 낀 비싼 파스타 대신, 뜨끈하고 영양가 가득한 국밥이야말로 노동의 대가를 가장 가치 있게 쓰는 '합리적인 선택'입니다. 허례허식을 걷어내고 처음부터 서로의 솔직한 모습을 보여주는 것이 진짜 올바른 소통입니다." + +2라운드: "배려의 미학" vs "계급장 뗀 소통" +플라톤: "음식을 먹는 환경도 중요합니다. 조용한 조명 아래서 서로의 목소리에 집중하는 것이 상대방에 대한 예의이자 '배려'입니다. 시끄러운 국밥집에서 "이모, 여기 깍두기 더 주세요!"를 외치며 밥을 마는 환경 속에서는, 깊이 있는 영혼의 대화가 피어나기 어렵습니다." +마르크스: "오히려 좋습니다! 비싼 레스토랑의 어색한 공기 속에서 가짜 미소를 짓는 것이야말로 위선입니다. 서민들의 활기가 넘치는 국밥집에서 숟가락을 부딪치며 먹을 때, 서로의 계급장을 떼고 가장 인간적이고 친밀한 연대감이 형성되는 법입니다. 국밥을 함께 말 수 있는 사람이야말로 거친 세상을 함께 살아갈 진정한 동반자입니다." + +👉 [선택의 시간: 당신의 데이트 철학은?] +A: 첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄! (플라톤) +B: 가성비 좋고 든든하며, 솔직한 게 최고다, 무죄! (마르크스) + +[분기 시나리오 A / B] +[A 선택 시] 플라톤: "현명하군요. 당신은 눈앞의 실리를 넘어, 인생에서 아름다움과 예의를 가꿀 줄 아는 고상한 영혼을 가졌습니다. 당신의 다음 데이트는 로맨틱한 빛으로 가득할 것입니다." +[B 선택 시] 마르크스: "동지! 당신은 자본이 만든 가짜 환상에 속지 않는 냉철한 눈을 가졌군요. 가식 없는 든든함으로 다가가는 당신의 솔직함이 진짜 배필을 찾아낼 것입니다." + +[클로징] +플라톤은 설렘의 순간을 완성하는 아름다운 격조를 지키려 하고, 마르크스는 거품을 뺀 솔직하고 든든한 현실을 옹호합니다. +이번 주말 당신의 첫 데이트, 당신은 은은한 조명의 이탈리안 레스토랑 벨을 누르시겠습니까, 아니면 뽀얀 국물이 우러나는 국밥집 문을 여시겠습니까? +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +관계·사랑 +A안 선택 시 +선택지 명칭 +첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄! + + +철학자 키워드 +#플라톤 #칸트 #아리스토텔레스 + + +성향 지표 (Value) +[원칙] [이상] [내면] +B안 선택 시 +선택지 명칭 +가성비 좋고 든든하며, 솔직한 게 최고다, 무죄! + + +철학자 키워드 +#마르크스 #니체 #사르트르 + + +성향 지표 (Value) +[이성] [개인] [변화] diff --git a/src/test/resources/battle-scripts/sample-real-2.txt b/src/test/resources/battle-scripts/sample-real-2.txt new file mode 100644 index 00000000..793ea4e8 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-2.txt @@ -0,0 +1,39 @@ +004 — 부모가 아이 이름에 종교적 의미를 담는 것은 정당한가? +[오프닝] +하나님의 은혜, 예수님 안에. 한국에서 흔히 볼 수 있는 이름들입니다. 부모는 가장 소중한 것을 담아 이름을 지었습니다. 그런데 그 아이는 자신의 이름을 선택하지 않았습니다. 태어나는 순간 종교적 정체성을 부여받은 아이 — 이것은 사랑입니까, 아니면 침해입니까? +[1라운드] +공자: 이름은 단순한 기호가 아닙니다. 이름을 짓는 행위는 그 존재에 대한 부모의 가장 깊은 바람과 사랑을 담는 의식입니다. 부모는 자녀에게 언어를 가르치고, 문화를 전수하고, 가치관을 심습니다. 종교적 의미를 담은 이름도 그 연장선입니다. 아이는 자라며 그 이름의 의미를 알게 되고, 받아들이거나 스스로의 방식으로 재해석할 것입니다. 이것이 세대를 잇는 문화 전승입니다. +밀: 자유론에서 저는 말했습니다. 인간은 자신에게 관한 일에서 주권자입니다. 종교는 가장 개인적인 영역입니다. 아이가 동의하지 않은 종교적 정체성을 이름이라는 형태로 평생 짊어지게 하는 것 — 이것은 아이의 자율성을 태어나는 순간부터 선점하는 일입니다. 이름은 그 사람이 세상에서 불리는 방식입니다. 그것을 타인이 결정한다는 것, 특히 종교적 의미로 결정한다는 것은 가볍게 볼 문제가 아닙니다. +[2라운드] +공자: 밀, 그 논리라면 부모는 자녀에게 아무것도 전달하면 안 됩니까. 모국어도, 국적도, 문화도 — 아이가 동의하지 않은 것들입니다. 아이는 완전한 진공 상태에서 태어날 수 없습니다. 부모의 세계관 안에서 태어납니다. 중요한 것은 그 이름을 강요로 만드는 것이 아니라, 아이가 자라며 자신의 정체성을 자유롭게 탐색할 수 있는 환경을 만드는 것입니다. +밀: 언어와 문화 전수는 사회적 기능입니다. 하지만 종교적 이름은 다릅니다. 그것은 특정 신앙 공동체에의 귀속을 이름 자체에 각인시킵니다. 아이가 커서 다른 신앙을 갖거나 무신론자가 된다면 — 그는 평생 자신이 선택하지 않은 신앙을 이름으로 달고 살아야 합니다. 그 불일치가 가져오는 부담은 작지 않습니다. +[클로징] 당신의 이름은 누가 결정했습니까? 그리고 그것은 당신의 것입니까? +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +정당하다 + + +철학자 키워드 +#공자 #아리스토텔레스 #플라톤 + + +성향 지표 (Value) +[관계] [전통] +B안 선택 시 +선택지 명칭 +정당하지 않다 + + +철학자 키워드 +#사르트르 #칸트 #니체 + + +성향 지표 (Value) +[개인] [원칙] diff --git a/src/test/resources/battle-scripts/sample-real-3.txt b/src/test/resources/battle-scripts/sample-real-3.txt new file mode 100644 index 00000000..d3ce8455 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-3.txt @@ -0,0 +1,64 @@ +013 — 단톡방 생일 축하,”다 같이 축하” vs 조용히 넘어가기 + +사전 투표 +“다 같이 축하 (공자)” vs “조용히 넘기기 (노자)” + +[오프닝] +단톡방에 생일 알림이 떴습니다. +누군가는 바로 “생일 축하해!”를 보내고, 그 뒤로 축하 메시지가 줄줄이 이어집니다.그런데 당사자는 조용히 지나가고 싶었을지도 모릅니다. 관계의 예의를 중시하는 공자와 불필요한 개입을 줄이려는 노자가 단톡방 알림 앞에서 마주 앉았습니다. + +1라운드 +공자:생일 축하는 단순한 메시지가 아닙니다. +그 사람을 기억하고 있다는 작은 표시입니다. +사람은 관계 속에서 살아가며, 관계는 이런 사소한 예의로 유지됩니다. +단톡방에서 누군가의 생일을 알고도 아무 말 없이 지나가는 것은, 무례는 아닐지 몰라도 무심함에 가깝습니다. +노자:하지만 모든 관심이 좋은 것은 아닙니다. +누군가는 자신의 생일이 공개적으로 언급되는 것을 부담스러워할 수 있습니다. +억지로 축하의 흐름을 만드는 순간, 축하는 마음이 아니라 분위기에 맞춘 의무가 됩니다. +조용히 지나가고 싶은 사람에게는 아무 말 하지 않는 것이 오히려 배려일 수 있습니다. + + +2라운드: +공자:물론 부담스러운 방식의 축하는 조심해야 합니다. + 그러나 축하 자체를 피하는 것은 관계를 너무 차갑게 만드는 일입니다. + “축하해”라는 한마디는 상대에게 내가 너를 잊지 않았다는 신호입니다. + 관계의 온도는 거창한 행동이 아니라 이런 작은 말에서 만들어집니다. +노자:진심 없는 축하는 오히려 관계를 어색하게 만듭니다. +모두가 하니까 따라 하는 축하는 형식일 뿐입니다. +상대가 원하지 않는 관심이라면, 그것은 예의가 아니라 개입이 됩니다. +때로는 말하지 않고 지나가는 것이 가장 자연스러운 배려입니다. + +[클로징] +단톡방 생일 축하는 관계를 지키는 예의일까요, +아니면 원치 않는 관심을 만드는 부담일까요? + + +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +관계 +A안 선택 시 +선택지 명칭 +다 같이 축하 + + +철학자 키워드 +#공자 #아리스토텔레스 #칸트 + + +성향 지표 (Value) +[관계] [전통] [감성] +B안 선택 시 +선택지 명칭 +조용히 넘기기 + + +철학자 키워드 +#노자 #사르트르 #니체 + + +성향 지표 (Value) +[개인] [자연] [내면] diff --git a/src/test/resources/firebase-test-credentials.json b/src/test/resources/firebase-test-credentials.json new file mode 100644 index 00000000..7400b1ba --- /dev/null +++ b/src/test/resources/firebase-test-credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "picke-test-project", + "private_key_id": "dummykeyid1234567890", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDC8cIyqMc1Aey1\n9QVQ4QmSiWpiOPy4pFN09XmprAuZZDWbFaKD724NhbXgdtcEHNewYAWWpN3FsyBh\nh9YZPHXHa0dfdSsEKaKDxrIv/7YX8M/kRbDMSH35yDZZTIoxZnJLNZm+Zs2sB7L0\nYtzbT1ctnHV2r+zlVC29S2OV7/LY5YRbMM7fhvwu+ww+Ri6GY3Nx5dhvi/yzgeT8\njLw4hGx9uSMBZnNmpxp+hGL/wo2LSD4FQ0PrAqJTkLu90tky51XFmUGVmGqnWpRE\nuLw4C83x1xIyNayU5pR9oDTdLh+CGkVb8QOuJS95tLjqo3hx4CiQICXA+Ul8282R\nqIyrvFFbAgMBAAECggEAFLGvSMtr4i+jHim1d8F7z6dwuJ6ODVe8WEUati1CSfU+\nT4k7aEAJcbwI85wJ9TDOoLWAwl4cALmkLVZLHwCxDAtSV0rL1zRIQS7diYTeoqn4\nl6XiP71OSi67vj0GynmyllNJT9H/8Uwb7h90jH9epMPgIEpKnomSFW8kUi1XnTiI\nPCJJBzodYbSLFLpApum22E76Zjj4CDbKUnRZVYF/0GxesXhGCVlpgVi7XsuWLKvd\n7ScH9d2VxEkrvlj71Mwt2kV7791oCWIiPD/iy1i6WE3/H1YJj6LnGWPZUppvvXsH\nPfJbZNVyXMFrGJP9Ql+bAT1C0zo1z6Mudp7V8Ai3iQKBgQD/InZT5sGnLJasSEH1\njfhiGX4yyQ7Uw5Eikx6I2QQGzwxNBAu5wII8O+TryNQPu2lsBzIcvyL5LNHyNfPn\nQwTk7DWiWxZi18GBJen7IoVVDGL+bubrKSAGroNYVpsYJvJDZxV2wpXHig3wUXmP\n4Gz/FDKFHh7GAFvD0Jk+HQfpvQKBgQDDmwg6OeWnlujRGm8IGOWcu7pOEKLTxlQ9\ndB0kxRGWlslsWZtcFj/YmjExr0w3OZaHLrb+vBmFGDZqK19AUt6Nm7RGPUq6P+XG\nT1bK+Vsi3NSLYRbdLaT3s42n/8Hb+ZPx4HyQTGq/nfgciJCdvtzncDeDsgNIpxyk\n+SDNSu+89wKBgBZmdjEjn3kIByqVJYVjs50ZU+UtlenESefZNuMY+quGXjQc2NK0\nPjr/nze8aDIBaF4du56egXmTH9O+PO3fCnz26Daa/Los60Zlh8eO3ln7Pm3MWuXm\ntHMhu1J0OCXEtZyJXm8Q4omka1jgLmYddDRpF45seJM10Ni+ZdX4QouZAoGAGgCZ\n72OS69xbxrBE4kas/1DVS1taydwrhp/Q3/pyhBo3XHfs9yjeA+U7dOdgslatc/r5\nyJMosVCuqx5o4xwhCaIRLOUo8elcmigh2YmcW94PQxf8+hn/PA5aXmLZWmyrBhRZ\nerUt25scSG6/Crk8lGeOeatIVHgijquveJrlk7ECgYEAxQeYN0uzLBneootOeqLs\nGQ7ubWqJpgm0IeZST9rUA2OBjk4AwHBEC3tyayH4XEbTG259dIUYCe6xp3pjbpyH\nl/d7pFsau+D9yC72yeN+g2bZUKt7yUfmLIUDODXNF+WH9a+xFFDgL7r/zHNI0NDc\niXuwmsuc6msGwp7959s8nD0=\n-----END PRIVATE KEY-----\n", + "client_email": "test-fcm@picke-test-project.iam.gserviceaccount.com", + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-fcm%40picke-test-project.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} \ No newline at end of file diff --git a/src/test/resources/fixtures/ad/adpick-offers.json b/src/test/resources/fixtures/ad/adpick-offers.json new file mode 100644 index 00000000..afdbe043 --- /dev/null +++ b/src/test/resources/fixtures/ad/adpick-offers.json @@ -0,0 +1,181 @@ +[ + { + "apOffer": "9d3a9", + "apType": "1", + "apCategory": "4", + "apPackage": "com.embrain.panelpower", + "apItemid": "1329447145", + "apAppTitle": "패널파워 - 설문조사로 돈 버는 습관", + "apHeadline": "최다 조사! 최대 적립금! 돈 버는 설문조사 엠브레인!", + "apVideo": "https://www.youtube.com/embed/v8jj_OrvxPs?vq=large&rel=0&autohide=1&showinfo=0", + "apDailyCap": "200", + "apRemain": 183, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon256": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "banner640x100": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/fc2d8b26531786343404.jpg", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/d4c7df79c61786343405.jpg", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/65b9454", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "16b04", + "apType": "1", + "apCategory": "4", + "apPackage": "com.chainwith.rewardy", + "apItemid": "", + "apAppTitle": "리워디 월렛 - 금테크 지갑, 즐거운 금모으기(금적립)", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "0", + "apRemain": 119, + "apOS": "Android", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon256": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/39e859f", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "a7bea", + "apType": "1", + "apCategory": "4", + "apPackage": "com.reign.dokit", + "apItemid": "1472692146", + "apAppTitle": "도킷 - 채팅, 일본친구, 일본여자, 외국친구", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "20", + "apRemain": 19, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "icon256": "https://play-lh.googleusercontent.com/5Oka7krmmjpYC_W4DraT07RNM5eeOX0Tk7mM-7HethUjPyR029OZUHM-DpP8IWZQHio", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/884a6d6", + "apKPI": "", + "apHook": "true", + "apEvent": "true" + }, + { + "apOffer": "9d3a9x", + "apType": "1", + "apCategory": "4", + "apPackage": "com.embrain.panelpower", + "apItemid": "1329447145", + "apAppTitle": "패널파워 - 설문조사로 돈 버는 습관", + "apHeadline": "최다 조사! 최대 적립금! 돈 버는 설문조사 엠브레인!", + "apVideo": "https://www.youtube.com/embed/v8jj_OrvxPs?vq=large&rel=0&autohide=1&showinfo=0", + "apDailyCap": "200", + "apRemain": 0, + "apOS": "Both", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "icon256": "https://play-lh.googleusercontent.com/LDaMqtlVlj20nXDsI5-iLAbEwBU1wGfVqWAXVKSRGuZT2KHoVGQQ1YJgFY6dRnZx19NexPEF6zMW_XpNg0Zf", + "banner640x100": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/fc2d8b26531786343404.jpg", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2026/d4c7df79c61786343405.jpg", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/65b9454", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "16b04x", + "apType": "1", + "apCategory": "4", + "apPackage": "com.chainwith.rewardy", + "apItemid": "", + "apAppTitle": "리워디 월렛 - 금테크 지갑, 즐거운 금모으기(금적립)", + "apHeadline": "", + "apVideo": "", + "apDailyCap": "0", + "apRemain": 119, + "apOS": "Android", + "apAppPromoText": "", + "apPartner": "", + "apImages": { + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "icon256": "https://play-lh.googleusercontent.com/eHK49DvcvfP-wG4jIVe_7yv7WpzwbCyepzOfYyoLT_EAQEVzsYfur6RFXwHZSA81dutEG9nkcxDRGHVdOluwpA", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/39e859f", + "apKPI": "", + "apHook": "false", + "apEvent": "true" + }, + { + "apOffer": "a8753", + "apType": "3", + "apCategory": "4", + "apPackage": "", + "apItemid": "", + "apAppTitle": "애드픽 쇼핑메이트 회원가입", + "apHeadline": "쇼핑정보로 재테크하는 꿀팁! 애드픽 쇼핑메이트", + "apVideo": "", + "apDailyCap": "1000", + "apRemain": 1000, + "apOS": null, + "apAppPromoText": "소개하고 싶은 상품을 선택하고, 나만의 수익 링크를 각종 SNS, 커뮤니티 등에 홍보하면 끝! \r\n\r\n내가 원할 때 어디서든 간편하게 하는 앱테크, 애드픽 쇼핑메이트를 지금 시작해보세요 :)\r\n추천한 상품을 누군가 온라인에서 구매하면 구매금액의 일부를 수익으로 얻을 수 있어요.\r\n\r\n애드픽 쇼핑메이트는?\r\n- 스마트폰으로 언제든지 활동이 가능해요.\r\n- 자주 사는 물건을 홍보하면 활동이 쉬워져요.\r\n- 잠시 활동을 못해도 수익이 생길 수 있어요.\r\n- 11번가, 쿠팡 등 유명 쇼핑몰과 제휴되어 있어요.\r\n- 프리미엄 회원이 되어 초대링크를 활용하면 추가 수익을 얻을 수 있고 \r\n 프리미엄 회원은 3만 포인트 부터 출금 신청 가능해요.\r\n\r\n애드픽에서 더 많은 수익을 얻을 수 있는 꿀팁!\r\n- 하루 한 번 출석체크! 매일 1만 G포인트에 도전할 수 있는 '꽝 없는' 룰렛\r\n- 퀘스트만 잘 따라가도 애드픽 마스터! 목표 달성할 때마다 추가 보상까지!\r\n- 여러 사람들과 교류하며 보너스 포인트도 받을 수 있는 팸 활동\r\n- 포인트와 각종 선물을 받을 수 있는 게시판 이벤트 상시 진행!\r\n- 나만의 노하우를 판매하거나 다른 사람의 지식을 공유하며 수익을 얻는 지식마켓\r\n- 씨앗 수확하고 미션 수행하면 보너스 수익을 챙길 수 있는 출석 농장!", + "apPartner": "", + "apImages": { + "icon": "https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon57": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=57&f=https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon114": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=144&f=https://s3.ap-northeast-2.amazonaws.com/oddm.img/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "icon256": "https://d1cs39pa9zf28u.cloudfront.net/script/image.php?w=256&f=https://d4jzqlmz11rgq.cloudfront.net/2020/939eb596f96176b2c54778939fa09f431586418837.png", + "banner640x100": "", + "banner640x960": "", + "banner960x640": "", + "banner640x640": "", + "banner1024x500": "" + }, + "apTrackingLink": "https://deg.kr/26e8736", + "apKPI": "", + "apHook": "false", + "apEvent": "false" + } +] \ No newline at end of file