diff --git a/.claude/commands/feat.md b/.claude/commands/feat.md index 34333bf0..14a73135 100644 --- a/.claude/commands/feat.md +++ b/.claude/commands/feat.md @@ -18,10 +18,11 @@ argument-hint: "<작업 설명 또는 계획서 경로>" - 가드레일 준수: FCM 경로·프로덕션 DB 파괴 작업 전 경고. - 사용자 격리(userId 소유권 검증) 누락 없이 구현. - 근거 없는 코드 추가 금지. 각 변경은 계획서 단계에 대응. + - 각 단위 구현 이후, 5번 섹션의 커밋 진행 4. **컴파일 확인** — 구현 완료 후 `./gradlew compileJava` 실행. 오류 있으면 수정. -5. **커밋 제안** — 논리 단위별 커밋 메시지 초안을 제시하고 사용자 확인 후 커밋. +5. **커밋 진행** — 논리 단위별로 알맞게 분리해 자체적으로 커밋 형식: `[Feat] 한 줄 요약\n- 상세` (테스트는 /test로 분리) diff --git a/.codex/scripts/agent-config-lint.sh b/.codex/scripts/agent-config-lint.sh index 8c9c03d9..82583a07 100755 --- a/.codex/scripts/agent-config-lint.sh +++ b/.codex/scripts/agent-config-lint.sh @@ -47,8 +47,13 @@ if grep -q '^/AGENTS\.md$' .gitignore; then error "AGENTS.md is ignored; shared agent guidance should be trackable" fi +# src/test/resources/application-test.yml 은 예외다. CI 테스트가 이 파일로 Testcontainers 환경을 띄워서 +# 커밋해야 하고(ddf9c69), 값은 전부 테스트 전용 더미라 운영 설정과 겹치지 않는다. +# 아래 secret_hits 검사는 이 파일에도 그대로 적용된다. tracked_secret_files="$( - git ls-files | grep -E '(^|/)(\.env(\..*)?|FirebaseAdminKey\.json|application(-(dev|prod|local|test))?\.ya?ml|application(-(dev|prod|local|test))?\.properties)$' || true + git ls-files \ + | grep -E '(^|/)(\.env(\..*)?|FirebaseAdminKey\.json|application(-(dev|prod|local|test))?\.ya?ml|application(-(dev|prod|local|test))?\.properties)$' \ + | grep -v -x 'src/test/resources/application-test\.yml' || true )" if [ -n "$tracked_secret_files" ]; then diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 00000000..41383f8d --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,230 @@ +name: Smoke Test + +# 배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. 자세한 내용은 scripts/smoke/README.md 에 있다. +# +# 언제 도는가 +# - 배포 워크플로가 끝난 뒤: 배포가 성공했으면 full(조회 + 스모크 계정 안의 쓰기 흐름), 실패했으면 read. +# - 매시 17분: prod 에 read. 맥미니나 네트워크가 죽은 것을 배포와 상관없이 잡는다. +# - 수동 실행: 대상과 단계를 고른다. bootstrap 은 스모크 계정을 처음 한 번 만들 때만 쓴다. +# +# 운영 서버에 영향을 주지 않으려고 지키는 것 +# - 배포 워크플로(ci-prod / deploy-prod / ci-dev / deploy-dev)는 수정하지 않았다. 끝난 것을 workflow_run 으로 받아서 +# 따로 돌기 때문에, 스모크 테스트가 틀려도 배포와 nginx 전환에는 아무 영향이 없다. +# - 맥미니 러너(self-hosted)가 아니라 GitHub 호스티드 러너에서 돈다. +# - 스크립트 자체 테스트가 먼저 통과해야 실제 서버에 요청을 보낸다. +# - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다. +# +# 필요한 Secrets (없으면 할 수 있는 단계까지만 한다) +# JWT_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL +# +# JWT_ACCESS_TOKEN_SECRET 은 배포 워크플로가 서버에 넘기는 그 키다(ci-dev.yml, ci-prod.yml). 스모크는 같은 키로 +# 토큰을 직접 만들어 쓰기 때문에 값이 같아야 하고, 따로 복사해 두면 키를 바꿀 때 한쪽만 남아 알림이 계속 울린다. +# +# workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다. + +on: + workflow_dispatch: + inputs: + target: + description: "확인할 서버" + type: choice + options: [prod, dev] + default: prod + level: + description: "reach: 닿는지만 / read: 조회 / full: 조회 + 쓰기 흐름 / bootstrap: 스모크 계정 만들기(처음 한 번)" + type: choice + options: [read, full, reach, bootstrap] + default: read + workflow_run: + workflows: + - "CI Build & Deploy Prod" + - "Deploy to Production Server (Manual Fallback)" + - "CI Build & Deploy Dev" + - "Deploy to Development Server (Manual Fallback)" + types: [completed] + schedule: + - cron: "17 * * * *" + +permissions: + contents: read + actions: read + +# 쓰기가 있는 실행(배포 성공 뒤 full, 수동 full, bootstrap)만 대상별로 한 줄로 세운다. 같은 스모크 계정에 쓰기가 겹치지 않게 한다. +# 조회만 하는 실행은 실행마다 그룹을 따로 둔다. 같은 그룹에 대기 중인 실행이 새 실행에 밀려 취소되는 일을 막는다. +concurrency: + group: >- + ${{ + ((github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'workflow_dispatch' && (inputs.level == 'full' || inputs.level == 'bootstrap'))) + && format('smoke-write-{0}', github.event_name == 'workflow_dispatch' && inputs.target || (contains(github.event.workflow_run.name, 'Dev') && 'dev' || 'prod')) + || format('smoke-read-{0}', github.run_id) + }} + cancel-in-progress: false + +jobs: + smoke: + name: 스모크 테스트 + # 배포가 취소되거나 건너뛰어졌으면 확인할 것이 없다. + if: github.event_name != 'workflow_run' || contains(fromJSON('["success","failure"]'), github.event.workflow_run.conclusion) + runs-on: ubuntu-latest + # 스크립트 자체 테스트 약 30초 + 스모크 실행 상한 200초 + 준비 시간 + timeout-minutes: 8 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: 대상과 단계 정하기 + id: plan + env: + EVENT: ${{ github.event_name }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_LEVEL: ${{ inputs.level }} + REF: ${{ github.ref }} + RUN_NAME: ${{ github.event.workflow_run.name }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + case "$EVENT" in + workflow_dispatch) + target="$INPUT_TARGET"; level="$INPUT_LEVEL" ;; + workflow_run) + # "CI Build & Deploy Dev", "Deploy to Development Server ..." 는 둘 다 Dev 를 포함한다. + case "$RUN_NAME" in *Dev*) target="dev" ;; *) target="prod" ;; esac + if [ "$RUN_CONCLUSION" = "success" ]; then level="full"; else level="read"; fi ;; + schedule) + target="prod"; level="read" ;; + *) + echo "알 수 없는 이벤트: $EVENT"; exit 2 ;; + esac + # 수동 실행은 아무 브랜치에서나 할 수 있어서, prod 는 main 의 스크립트로만 돌린다. + if [ "$EVENT" = "workflow_dispatch" ] && [ "$target" = "prod" ] && [ "$REF" != "refs/heads/main" ]; then + echo "prod 대상 수동 실행은 main 브랜치에서만 할 수 있습니다 (현재 $REF)"; exit 2 + fi + case "$target" in + prod) url="https://ono-prod.seungminki.shop" ;; + dev) url="https://ono-dev.seungminki.shop" ;; + *) echo "알 수 없는 대상: $target"; exit 2 ;; + esac + { + echo "target=$target" + echo "level=$level" + echo "url=$url" + } >> "$GITHUB_OUTPUT" + echo "대상 $target ($url), 단계 $level" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: 스크립트 자체 테스트 + run: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' + + # 대상 서버의 시크릿만 그 스텝에 넘긴다. 한 스텝에 dev 와 prod 시크릿을 같이 두지 않는다. + - name: 스모크 테스트 (prod) + id: smoke_prod + if: steps.plan.outputs.target == 'prod' + env: + SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} + SMOKE_LEVEL: ${{ steps.plan.outputs.level }} + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} + run: | + if [ "$SMOKE_LEVEL" = "bootstrap" ]; then + unset SMOKE_ACCESS_TOKEN_SECRET + python3 scripts/smoke/smoke_test.py bootstrap + else + python3 scripts/smoke/smoke_test.py + fi + + - name: 스모크 테스트 (dev) + id: smoke_dev + if: steps.plan.outputs.target == 'dev' + env: + SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} + SMOKE_LEVEL: ${{ steps.plan.outputs.level }} + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} + SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }} + run: | + if [ "$SMOKE_LEVEL" = "bootstrap" ]; then + unset SMOKE_ACCESS_TOKEN_SECRET + python3 scripts/smoke/smoke_test.py bootstrap + else + python3 scripts/smoke/smoke_test.py + fi + + # 알림 규칙 + # - 배포 뒤 확인: 통과와 실패 모두 알린다. + # - 매시 확인: 정상에서 실패로 바뀔 때와 복구될 때 알린다. 실패가 이어지면 6시간마다 한 번 다시 알린다. + # 첫 알림 전송이 실패해도 늦어도 6시간 뒤에는 알림이 가게 하려는 것이다. + # - 수동 실행과 bootstrap: 알리지 않는다. 실행한 사람이 화면에서 본다. + - name: Discord 알림 + if: always() && steps.plan.outputs.level != 'bootstrap' && github.event_name != 'workflow_dispatch' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + EVENT: ${{ github.event_name }} + TARGET: ${{ steps.plan.outputs.target }} + LEVEL: ${{ steps.plan.outputs.level }} + # 대상이 아닌 스텝의 outcome 은 빈 값이 아니라 skipped 라서, 대상으로 골라야 한다. + OUTCOME: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outcome || steps.smoke_dev.outcome }} + FIRST_FAILURE: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outputs.first_failure || steps.smoke_dev.outputs.first_failure }} + FAILED_COUNT: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outputs.failed || steps.smoke_dev.outputs.failed }} + DEPLOY_NAME: ${{ github.event.workflow_run.name }} + DEPLOY_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "DISCORD_WEBHOOK_URL 이 없어 알림을 건너뜁니다"; exit 0 + fi + if [ "$OUTCOME" = "success" ]; then passed=true; else passed=false; fi + + streak=0 + if [ "$EVENT" = "schedule" ]; then + # 직전 매시 실행들이 연속으로 몇 번 성공하지 못했는지 센다. cancelled, timed_out 도 실패로 본다. + # 조회에 실패하면 알림을 보내는 쪽으로 기운다(streak=0). + conclusions=$(gh api "repos/$REPO/actions/workflows/smoke-test.yml/runs?event=schedule&status=completed&per_page=12" \ + --jq '.workflow_runs[].conclusion' 2>/dev/null || true) + previous="" + for c in $conclusions; do + [ -z "$previous" ] && previous="$c" + if [ "$c" = "success" ]; then break; fi + streak=$((streak + 1)) + done + if $passed && [ "$streak" -eq 0 ]; then echo "정상 유지, 알림 없음"; exit 0; fi + # 직전이 failure 일 때만 "이미 알렸다"고 본다. cancelled 나 timed_out 은 알림 스텝이 못 돌았을 수 있다. + if ! $passed && [ "$previous" = "failure" ] && [ $((streak % 6)) -ne 0 ]; then + echo "장애 지속 (직전 연속 실패 $streak 회), 6회마다 다시 알립니다"; exit 0 + fi + fi + + export PASSED=$passed STREAK=$streak + python3 - <<'PY' > payload.json + import json, os + env = os.environ + passed = env["PASSED"] == "true" + event, target, level = env["EVENT"], env["TARGET"], env["LEVEL"] + if event == "schedule": + streak = int(env.get("STREAK") or 0) + if passed: + title = f"✅ 스모크 테스트 복구 ({target})" + elif streak: + title = f"🚨 스모크 테스트 실패 {streak + 1}시간째 ({target})" + else: + title = f"🚨 스모크 테스트 실패 ({target})" + context = "매시 확인" + else: + title = f"{'✅' if passed else '🚨'} 배포 후 스모크 테스트 {'통과' if passed else '실패'} ({target})" + context = f"{env.get('DEPLOY_NAME', '')} ({env.get('DEPLOY_CONCLUSION', '')}) 뒤 확인" + lines = [f"{context}, 단계 `{level}`"] + if not passed: + lines.append(f"실패 {env.get('FAILED_COUNT') or '?'}건. 첫 실패: {env.get('FIRST_FAILURE') or '스크립트가 끝까지 돌지 못했습니다'}") + lines.append(env["RUN_URL"]) + # 실패 문구에 서버 응답 일부가 들어가므로 @everyone 같은 멘션이 발동하지 않게 막는다. + print(json.dumps({"allowed_mentions": {"parse": []}, + "embeds": [{"title": title, "description": "\n".join(lines), + "color": 3066993 if passed else 15158332}]})) + PY + curl -fsS -X POST "$WEBHOOK" -H "Content-Type: application/json" --data @payload.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..57c73372 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,144 @@ +name: Test + +# main / develop 으로 합치기 전에 반드시 통과해야 하는 게이트. +# 기존 ci-dev / ci-prod 는 workflow_dispatch 수동 실행이고 `-x test` 로 테스트를 건너뛴다. +# 배포 파이프라인은 그대로 두고, 검증만 이 워크플로가 책임진다. +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + workflow_dispatch: + +# 같은 PR에 연달아 푸시하면 앞선 실행은 취소한다. +concurrency: + group: test-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: 격리 환경 테스트 + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # 테스트 프로필은 DB/Redis/RabbitMQ 를 전부 Testcontainers 로 띄우므로 + # 외부 인프라도, 시크릿도 필요 없다. 다만 FcmConfig 의 @PostConstruct 가 + # 키 파일을 읽지 못하면 컨텍스트가 뜨지 않아 더미 파일만 만들어 준다. + # (실제 발송은 IntegrationTestSupport 에서 FcmService 를 목으로 잡아 차단한다) + - name: Create dummy FirebaseAdminKey.json + run: | + cat > src/test/resources/FirebaseAdminKey.json <<'JSON' + { + "type": "authorized_user", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token" + } + JSON + + # docker-java 는 기본으로 API v1.32 를 쓰는데 최신 엔진은 이를 거부한다. + # 러너에 실제로 올라간 엔진의 API 버전을 그대로 넘긴다. + - name: Resolve Docker API version + run: | + echo "DOCKER_API_VERSION=$(docker version --format '{{.Server.APIVersion}}')" >> "$GITHUB_ENV" + docker version --format 'Docker Engine {{.Server.Version}} / API {{.Server.APIVersion}}' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test jacocoTestReport --no-daemon + + # 커버리지 하한선(라인 85% / 분기 70%) 검사. 테스트 없이 코드만 늘면 여기서 걸린다. + # 테스트가 실패해도 커버리지 요약은 보이도록 always() 로 둔다. + - name: Verify coverage floor + if: always() + run: ./gradlew jacocoTestCoverageVerification --no-daemon + + # 뮤테이션 테스트는 PR 마다 돌리기에는 느려서 여기서는 돌리지 않는다. + # 로컬에서 `./gradlew pitest` 로 실행하고, 결과는 build/reports/pitest/index.html 에서 본다. + # 커버리지가 "그 줄이 실행됐다"만 말하는 것과 달리 + # 뮤테이션 점수는 "코드가 틀렸을 때 테스트가 실패하는가"를 측정한다. + + - name: Summarize results + if: always() + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import glob, re, os + + total = failed = skipped = 0 + failures = [] + for path in glob.glob('build/test-results/test/*.xml'): + head = open(path, encoding='utf-8', errors='replace').read(4000) + m = re.search(r'tests="(\d+)"[^>]*skipped="(\d+)" failures="(\d+)" errors="(\d+)"', head) + if not m: + continue + total += int(m.group(1)) + skipped += int(m.group(2)) + failed += int(m.group(3)) + int(m.group(4)) + + for path in glob.glob('build/test-results/test/*.xml'): + body = open(path, encoding='utf-8', errors='replace').read() + for case in re.finditer(r']*?>(.*?)(?= 50: + print(f'- 외 {len(failures) - 50}건') + print() + + report = 'build/reports/jacoco/test/jacocoTestReport.xml' + if os.path.exists(report): + xml = open(report, encoding='utf-8').read() + tail = xml[xml.rfind(''):] + print('## 커버리지\n') + print('| 지표 | 커버 | 전체 | 비율 |') + print('|---|---:|---:|---:|') + labels = {'INSTRUCTION': '명령어', 'BRANCH': '분기', 'LINE': '라인', 'METHOD': '메서드', 'CLASS': '클래스'} + for c in re.finditer(r'/dev/null); \ + if [ -z "$$cid" ]; then \ + printf '%-12s %-10s %-10s %s\n' "$$s" "down" "-" "-"; \ + else \ + state=$$(docker inspect -f '{{.State.Status}}' $$cid); \ + health=$$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $$cid); \ + ports=$$(docker inspect -f '{{range $$p, $$b := .NetworkSettings.Ports}}{{range $$b}}{{.HostPort}} {{end}}{{end}}' $$cid); \ + printf '%-12s %-10s %-10s %s\n' "$$s" "$$state" "$$health" "$$ports"; \ + fi; \ + done + +ps: ## 컴포즈 기본 상태 출력 + @$(COMPOSE) ps + +logs: ## 인프라 로그 따라가기 (S=redis 로 특정 서비스만) + @$(COMPOSE) logs -f $(S) + +test: ## 전체 테스트 + 커버리지 리포트 + @./gradlew test jacocoTestReport + +test-only: ## 특정 테스트만 실행 (T=com.aisip.OnO.backend.tag.*) + @./gradlew test --tests "$(T)" + +coverage: ## 커버리지 리포트를 브라우저로 열기 + @open build/reports/jacoco/test/html/index.html diff --git a/build.gradle b/build.gradle index 331b7e75..6d1eb72d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,10 @@ plugins { id 'java' + id 'jacoco' + // 뮤테이션 테스트. 커버리지는 "그 줄이 실행됐다"만 말하지만 + // 뮤테이션 점수는 "코드가 틀렸을 때 테스트가 실제로 실패하는가"를 측정한다. + id 'info.solidsoft.pitest' version '1.15.0' id 'org.springframework.boot' version '3.3.1' id 'io.spring.dependency-management' version '1.1.5' id "io.sentry.jvm.gradle" version "4.11.0" @@ -33,6 +37,10 @@ jar { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.1.0' implementation 'org.springframework.boot:spring-boot-starter-web' + // Bean Validation 구현체. 이게 없으면 jakarta.validation-api 만 클래스패스에 있어서 + // 코드에 붙은 @NotNull/@Valid/@Validated 가 전부 무시되고, 잘못된 요청이 검증 없이 + // 서비스로 들어가 500 이 된다. + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' @@ -67,8 +75,16 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + // Spring Boot 3.3.1 이 관리하는 1.19.8 은 docker-java 3.3.x 를 쓰는데, + // Docker Engine 29 의 /info 응답에 400 을 내며 컨테이너를 띄우지 못한다. 버전을 고정해 올린다. + testImplementation platform('org.testcontainers:testcontainers-bom:1.21.3') + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:rabbitmq' + // 아키텍처 규칙을 테스트로 강제한다. 오늘 고친 결함들이 같은 형태로 재발하지 않도록 막는다. + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' @@ -82,6 +98,105 @@ dependencies { tasks.named('test') { useJUnitPlatform() systemProperty 'spring.profiles.active', 'test' + + // 런타임은 Dockerfile 의 ENTRYPOINT 에서 -Duser.timezone=Asia/Seoul 로 시간대를 못박는데 + // 테스트만 빠져 있었다. GitHub Actions 는 UTC 로 돌기 때문에, 운영과 다른 시간대에서 검증하고 있었다. + // + // 이 코드에는 두 기준이 섞여 있다. MissionPeriodKey 는 KST 를 명시해서 하루를 가르는데 + // JPA Auditing 이 채우는 created_at 은 JVM 기본 시간대를 쓴다. 둘이 다르면 + // KST 00:00~09:00 구간에 하루가 어긋난다. 그 시각에만 깨지는 종류라 평소에는 초록으로 보인다. + systemProperty 'user.timezone', 'Asia/Seoul' + // 꾸미기 골든 파일 갱신 플래그. Gradle 은 커맨드라인 -D 를 테스트 JVM 에 물려주지 않아 + // 여기서 직접 넘겨야 -Dcosmetic.golden.update=true 가 CosmeticGoldenResponseTest 에 닿는다. + // (환경 변수 COSMETIC_GOLDEN_UPDATE=true 는 이 설정 없이도 그대로 전달된다.) + if (System.getProperty('cosmetic.golden.update') != null) { + systemProperty 'cosmetic.golden.update', System.getProperty('cosmetic.golden.update') + } + + // 기존 스위트는 힙 부족으로 OOM 종료됐다. 컨텍스트 캐시 + 컨테이너 구동분을 감안해 상향한다. + maxHeapSize = '2g' + + // macOS Docker Desktop 은 소켓을 ~/.docker/run/docker.sock 에 두기 때문에 + // Testcontainers 의 기본 탐색(/var/run/docker.sock)이 실패한다. 환경별로 찾아서 넘긴다. + def userSocket = file("${System.getProperty('user.home')}/.docker/run/docker.sock") + def dockerHost = System.getenv('DOCKER_HOST') + ?: (userSocket.exists() ? "unix://${userSocket.absolutePath}" : 'unix:///var/run/docker.sock') + environment 'DOCKER_HOST', dockerHost + // docker-java 는 기본으로 API v1.32 를 쓰는데 Docker Engine 29 의 MinAPIVersion 은 1.40 이라 + // /v1.32/info 가 400 으로 거부된다("Could not find a valid Docker environment"의 실제 원인). + // docker-java 는 이 값을 환경변수가 아니라 'api.version' 시스템 프로퍼티에서 읽는다. + systemProperty 'api.version', System.getenv('DOCKER_API_VERSION') ?: '1.44' + environment 'DOCKER_API_VERSION', System.getenv('DOCKER_API_VERSION') ?: '1.44' + environment 'TESTCONTAINERS_CHECKS_DISABLE', 'true' + testLogging { + events 'failed' + // short 는 예외 체인의 클래스명만 남기고 메시지를 버린다. + // application-test.yml 이 커밋되지 않아 CI 가 전멸했을 때, 이 설정 탓에 + // "Flyway 가 실패했다"까지만 보이고 어떤 SQL 인지 알 수 없어 진단이 크게 늦어졌다. + // 실패 원인이 로그만으로 드러나도록 full 을 유지한다. + exceptionFormat 'full' + showStackTraces = true + showCauses = true + } + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = '0.8.12' +} + +// 커버리지 집계에서 제외할 대상: 실행 진입점, 설정 클래스, QueryDSL 생성 코드 +// +// 주의: 여기 패턴은 Ant 스타일이라 '[A-Z]' 같은 문자 클래스를 지원하지 않는다. +// 처음에 '**/Q[A-Z]*' 로 적었더니 아무것도 걸러지지 않아 QueryDSL 생성 클래스 32개가 +// 그대로 집계에 들어갔고, 그 탓에 studyroom/entity 커버리지가 실제 100% 인데 45% 로 보였다. +// +// 'Q*' 로 시작하는 main 클래스 중 QueryDSL 생성물이 아닌 것은 QuartzConfig 하나뿐이다. +// 이것도 함께 빠지지만 Spring @Configuration 클래스라 이미 제외 중인 '**/config/**' 과 같은 성격이다. +def coverageExcludes = [ + '**/BackendApplication*', + '**/config/**', + '**/Q*.class', +] + +tasks.named('jacocoTestReport') { + // dependsOn test 를 걸면 안 된다. 테스트가 하나라도 실패하면 의존 태스크가 실패로 간주되어 + // 리포트 태스크가 통째로 건너뛰어지고, 직전 실행의 낡은 리포트가 그대로 남는다. + // test 의 finalizedBy 로 이미 순서가 보장되며, 실패한 실행에서도 커버리지는 나와야 한다. + reports { + xml.required = true + html.required = true + } + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, exclude: coverageExcludes) + }) + ) +} + +tasks.named('jacocoTestCoverageVerification') { + // 현재 수치(라인 94.6%, 분기 77.2%)보다 낮게 잡아 정상적인 변동은 통과시키되, + // 테스트 없이 코드가 늘어나면 걸리도록 한다. 커버리지가 오르면 함께 올린다. + violationRules { + rule { + element = 'BUNDLE' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.85 + } + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.70 + } + } + } + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, exclude: coverageExcludes) + }) + ) } tasks.withType(JavaCompile) { @@ -91,3 +206,63 @@ tasks.withType(JavaCompile) { clean { delete file('src/main/generated') } + +// ── 뮤테이션 테스트 ──────────────────────────────────────────────────────────── +// 프로덕션 코드를 일부러 변형(조건 반전, 반환값 치환, 호출 제거)한 뒤 +// 테스트가 그 변형을 잡아내는지 본다. 잡아내지 못한 변형(survived)은 +// "그 줄은 실행되지만 결과를 검증하는 단언이 없다"는 뜻이다. +// +// 전체를 돌리면 매우 오래 걸리므로 비즈니스 규칙이 몰린 패키지만 대상으로 한다. +// 실행: ./gradlew pitest (리포트: build/reports/pitest/index.html) +pitest { + junit5PluginVersion = '1.2.1' + // 순수 도메인 로직에 한정한다. + // + // 서비스 계층 전체를 대상으로 돌려 보면 뮤테이션 점수가 25% 로 나오는데, 이 숫자는 + // 테스트 품질이 아니라 측정 실패를 뜻한다. PIT 는 뮤테이션마다 어떤 테스트를 돌릴지 + // 고르려고 테스트별 라인 커버리지를 수집하는데, 우리 스위트는 대부분 @SpringBootTest + // 통합 테스트라 그 매핑이 잘 되지 않아 2899개 중 2041개가 "커버리지 없음" 으로 빠졌다. + // (같은 실행의 test strength 는 83% 였다. 테스트가 실제로 닿은 곳은 잘 잡고 있다는 뜻이다.) + // + // 스프링 컨텍스트 없이 도는 단위 테스트가 붙어 있는 엔티티·정책 클래스로 좁히면 + // 숫자가 실제 검출력을 가리킨다. 통합 테스트 영역은 쿼리 수 회귀 테스트와 + // 아키텍처 규칙이 따로 지킨다. + targetClasses = [ + 'com.aisip.OnO.backend.*.entity.*', + 'com.aisip.OnO.backend.problem.reminder.*Policy*', + 'com.aisip.OnO.backend.problem.reminder.*Calculator*', + 'com.aisip.OnO.backend.common.emoji.*', + 'com.aisip.OnO.backend.common.exception.*', + // 미션 기간 키. 스프링 없이 도는 순수 정적 유틸이고 단위 테스트가 붙어 있어 + // 위 주석이 말하는 "숫자가 실제 검출력을 가리키는" 유형에 해당한다. + // 나머지 미션 서비스는 통합 테스트로만 덮여 있어 넣어도 NO_COVERAGE 로 빠진다. + 'com.aisip.OnO.backend.mission.service.MissionPeriodKey', + ] + // targetTests 를 생략하면 PIT 는 targetClasses 를 테스트 패턴으로도 그대로 쓴다. + // 위 패턴들이 '*' 로 끝나 우연히 테스트 클래스까지 걸리고 있었을 뿐이라, + // 정확한 클래스명으로 대상을 추가하면 그 클래스의 테스트는 하나도 안 걸려 전부 NO_COVERAGE 가 된다 + // (MissionPeriodKey 를 넣었더니 7개 뮤턴트가 전부 그렇게 빠졌다). + // 지금까지의 암묵적 동작을 그대로 적고, 새로 넣은 대상의 테스트만 덧붙인다. + targetTests = [ + 'com.aisip.OnO.backend.*.entity.*', + 'com.aisip.OnO.backend.problem.reminder.*Policy*', + 'com.aisip.OnO.backend.problem.reminder.*Calculator*', + 'com.aisip.OnO.backend.common.emoji.*', + 'com.aisip.OnO.backend.common.exception.*', + 'com.aisip.OnO.backend.mission.service.MissionPeriodKeyTest', + ] + excludedClasses = [ + 'com.aisip.OnO.backend.**.Q*', + 'com.aisip.OnO.backend.**.*Dto', + 'com.aisip.OnO.backend.**.*Config', + ] + // 기본 뮤테이터에 더해 경계 조건과 널 반환을 다룬다. + // 오늘 찾은 결함 다수가 경계값(255/256, 자정 경계)에서 나왔다. + mutators = ['DEFAULTS', 'CONDITIONALS_BOUNDARY', 'NULL_RETURNS', 'REMOVE_CONDITIONALS'] + threads = 4 + timestampedReports = false + outputFormats = ['HTML', 'XML'] + // 처음부터 높게 잡으면 통과 자체가 불가능하다. 실측 후 단계적으로 올린다. + mutationThreshold = 0 + timeoutConstInMillis = 10000 +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c19aba8b..2870d6fe 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -98,6 +98,8 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. 자세한 이유는 docker-compose.prod.yml 참고 + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus @@ -129,6 +131,9 @@ services: command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus + # dev 는 회귀 확인 용도라 prod 보다 짧게 둔다 (기본값은 15일) + - --storage.tsdb.retention.time=30d + - --storage.tsdb.retention.size=5GB - --web.external-url=${PROMETHEUS_DEV_EXTERNAL_URL:-http://localhost:9091} - --web.route-prefix=${PROMETHEUS_DEV_ROUTE_PREFIX:-/} ports: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..5e28438b --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,96 @@ +# 로컬 개발용 인프라 (MySQL / Redis / RabbitMQ). +# +# 그동안 MySQL만 컴포즈로 뜨고 Redis·RabbitMQ 는 docker run 으로 따로 띄워져 있었다. +# 그래서 `docker compose ps` 에 셋이 같이 안 잡히고, 매번 `docker ps` 로 눈으로 확인해야 했다. +# 셋을 한 파일로 묶고 전부 헬스체크를 달아 상태를 한 번에 볼 수 있게 한다. +# +# 포트는 application-local.yml 이 기대하는 값과 맞췄다 (MySQL 3306 / Redis 6380 / RabbitMQ 5673). +# 비밀번호는 .env 에서 읽는다. .env 는 gitignore 되어 있고 이 파일에는 값을 적지 않는다. +# +# make up 띄우기 (healthy 될 때까지 대기) +# make status 셋 다 살아있는지 한눈에 +# make down 내리기 (데이터는 남는다) +# +# 주의: 이 파일은 프로젝트 이름을 따로 지정하지 않는다. 디렉터리명(backend)이 그대로 +# 프로젝트명이 되어야 기존 MySQL 볼륨 backend_mysql_data 를 그대로 이어받는다. +# 테스트는 이 컨테이너들을 쓰지 않는다. Testcontainers 가 별도 컨테이너를 띄운다. + +services: + mysql: + image: mysql:8.0 + container_name: ono-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?".env 에 MYSQL_ROOT_PASSWORD 가 필요하다"} + MYSQL_DATABASE: ono_db + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+09:00 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - ono-local + + redis: + image: redis:7.2-alpine + container_name: ono-redis + restart: unless-stopped + # 로컬 앱은 6380 을 본다. 컨테이너 안은 표준 6379 그대로 둔다. + ports: + - "6380:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + networks: + - ono-local + + rabbitmq: + image: rabbitmq:3.13-management-alpine + container_name: ono-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?".env 에 RABBITMQ_PASSWORD 가 필요하다"} + TZ: Asia/Seoul + ports: + - "5673:5672" # AMQP + - "15673:15672" # 관리 UI http://localhost:15673 + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + networks: + - ono-local + +networks: + ono-local: + driver: bridge + +volumes: + # 기존에 쓰던 볼륨을 그대로 지정한다. 이름을 바꾸면 개발 DB 데이터가 사라진다. + mysql_data: + name: backend_mysql_data + redis_data: + name: ono_local_redis_data + rabbitmq_data: + name: ono_local_rabbitmq_data diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index edab90d4..b928c4ed 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -97,6 +97,12 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. + # SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 돌면 GlobalExceptionHandler 가 + # 처리한 4xx 까지 전부 fatal 로 Sentry 에 올라간다. 봇 스캐너가 만드는 404/405 가 + # 미해결 이벤트의 대부분(7일 301건)을 차지하던 원인이 이 오버라이드였다. + # 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고된다. + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus @@ -151,6 +157,12 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. + # SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 돌면 GlobalExceptionHandler 가 + # 처리한 4xx 까지 전부 fatal 로 Sentry 에 올라간다. 봇 스캐너가 만드는 404/405 가 + # 미해결 이벤트의 대부분(7일 301건)을 차지하던 원인이 이 오버라이드였다. + # 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고된다. + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus @@ -184,6 +196,11 @@ services: command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus + # 보존 기간을 지정하지 않으면 기본 15일이라 월 단위 비교가 불가능하다. + # 현재 시계열 약 12,000개 / 스크레이프 60초 기준 하루 약 28MB, 90일이면 약 2.5GB. + # size 는 디스크가 가득 차는 것을 막는 안전장치이고, 둘 중 먼저 걸리는 쪽이 적용된다. + - --storage.tsdb.retention.time=90d + - --storage.tsdb.retention.size=10GB - --web.external-url=${PROMETHEUS_PROD_EXTERNAL_URL:-http://localhost:9090} - --web.route-prefix=${PROMETHEUS_PROD_ROUTE_PREFIX:-/} ports: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b093a44c..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,131 +0,0 @@ -version: '3.8' - -services: - # MySQL Database - mysql: - image: mysql:8.0 - container_name: ono-mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_DATABASE: ono_db - TZ: Asia/Seoul - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --default-time-zone=+09:00 - networks: - - ono-network - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] - interval: 10s - timeout: 5s - retries: 5 - - # Redis Cache - redis: - image: redis:7.2-alpine - container_name: ono-redis - restart: unless-stopped - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - ono-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - command: redis-server --appendonly yes - - # RabbitMQ Message Queue - rabbitmq: - image: rabbitmq:3.13-management-alpine - container_name: ono-rabbitmq - restart: unless-stopped - ports: - - "5672:5672" # AMQP 프로토콜 - - "15672:15672" # 관리 UI - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} - TZ: Asia/Seoul - volumes: - - rabbitmq_data:/var/lib/rabbitmq - networks: - - ono-network - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - # Spring Boot Application - app: - image: ${DOCKER_USERNAME}/ono:latest - container_name: ono-app - restart: unless-stopped - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/ono_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 - SPRING_RABBITMQ_HOST: rabbitmq - SPRING_RABBITMQ_PORT: 5672 - SPRING_RABBITMQ_USERNAME: ${RABBITMQ_USER:-admin} - SPRING_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD:-admin} - # 추가 환경 변수는 .env 파일에서 주입 - SPRING_CRYPTO_SECRET_KEY: ${SPRING_CRYPTO_SECRET_KEY} - JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET} - JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION} - JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET} - JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION} - CLOUD_AWS_S3_BUCKET: ${CLOUD_AWS_S3_BUCKET} - CLOUD_AWS_CREDENTIALS_ACCESS_KEY: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY} - CLOUD_AWS_CREDENTIALS_SECRET_KEY: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY} - DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} - OPENAI_API_KEY: ${OPENAI_API_KEY} - SENTRY_DSN: ${SENTRY_DSN} - ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} - ADMIN_PASSWORD: ${ADMIN_PASSWORD} - volumes: - - app_logs:/app/logs - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - networks: - - ono-network - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - -networks: - ono-network: - driver: bridge - -volumes: - mysql_data: - driver: local - redis_data: - driver: local - rabbitmq_data: - driver: local - app_logs: - driver: local \ No newline at end of file diff --git a/monitoring/alerts/ono-alerts.yml b/monitoring/alerts/ono-alerts.yml index f445fc15..06ac819b 100644 --- a/monitoring/alerts/ono-alerts.yml +++ b/monitoring/alerts/ono-alerts.yml @@ -53,12 +53,27 @@ groups: summary: "High API p95 latency on {{ $labels.job }}" description: "HTTP API p95 latency is above 1.5s for 10 minutes." + # 분모에서 봇 트래픽을 뺀다. 전체 요청의 92%가 /api 밖 경로(스캐너)라 + # 그대로 두면 실제 API 의 5xx 비율이 1/13 로 희석된다. - alert: OnOBackendHigh5xxRatio - expr: (sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5.."}[5m])) / clamp_min(sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*"}[5m])), 1e-9)) > 0.05 + expr: (sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5..",uri=~"/api.*"}[5m])) / clamp_min(sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",uri=~"/api.*"}[5m])), 1e-9)) > 0.05 for: 10m labels: severity: warning service: ono-backend annotations: summary: "High 5xx ratio on {{ $labels.job }}" - description: "5xx ratio is above 5% for 10 minutes." + description: "API 5xx ratio is above 5% for 10 minutes." + + # API 트래픽이 하루 약 780건(분당 0.5건)이라 비율+for:10m 조합으로는 + # 짧게 터지는 장애를 못 잡는다. 실제로 8/30 태그 중복 500 12건, 8/25 memo 500 3건이 + # 모두 몇 분 안에 끝나 위 규칙을 통과했다. 건수 기준으로 한 번 더 본다. + - alert: OnOBackendApi5xxBurst + expr: sum by (job) (increase(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5..",uri=~"/api.*"}[10m])) >= 3 + for: 0m + labels: + severity: warning + service: ono-backend + annotations: + summary: "API 5xx burst on {{ $labels.job }}" + description: "10분 동안 /api 경로에서 5xx 가 3건 이상 발생했습니다." diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md new file mode 100644 index 00000000..a3d72acf --- /dev/null +++ b/scripts/smoke/README.md @@ -0,0 +1,117 @@ +# 스모크 테스트 + +배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. +컨테이너 healthcheck 는 컨테이너 안의 `localhost:8081/actuator/health` 만 봐서, 그 앞의 Cloudflare 와 nginx, +JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 맥미니가 꺼져도 알 수 없다. + +## 언제 도는가 + +| 언제 | 대상 | 단계 | 알림 | +|---|---|---|---| +| 배포 워크플로가 끝난 뒤 (`workflow_run`) | 그 배포의 서버 | 배포 성공이면 `full`, 실패면 `read` | 통과와 실패 모두 Discord 로 | +| 매시 17분 (`schedule`) | prod | `read` | 실패로 바뀔 때와 복구될 때. 실패가 이어지면 6시간마다 다시 | +| 수동 실행 (`workflow_dispatch`) | 고른 서버 | 고른 단계 | 없음. prod 는 `main` 브랜치에서만 실행된다 | + +배포 워크플로 파일은 건드리지 않았다. 배포가 끝난 것을 받아서 따로 도는 방식이라, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. +맥미니 러너가 아니라 GitHub 호스티드 러너에서 돌기 때문에 운영 기계의 자원도 쓰지 않는다. + +## 무엇을 확인하나 + +| 단계 | 확인 | 요청 수 | +|---|---|---:| +| `reach` | 토큰 없이 `GET /api/problems/problemCount` 가 `401 (1007)` 인가. Cloudflare, nginx, 앱의 인증 필터까지 닿았다는 뜻이다 | 1 | +| `read` | 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 17 | +| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인 | 28 | + +`read` 의 조회 API 는 폴더 목록과 썸네일, 폴더 단건, 하위 폴더, 오답노트 수, 복습할 문제, 내 오답노트, 폴더 안 오답노트, +복습노트 썸네일과 전체, 풀이 기록 수, 내 스터디룸, 태그, 학습 캘린더, 학습 리포트 요약이다. +운영 main 버전과 develop 버전 양쪽에 다 있는 API 만 골랐다. + +## 운영 서버에 영향이 없는 이유 + +- **보낼 수 있는 요청이 스크립트에 고정되어 있다.** `ALLOWED_REQUESTS` 밖의 요청은 보내기 전에 막는다. 쓰기는 `full` 에서만, 게스트 가입은 `bootstrap` 에서만 허용한다. +- **스모크 계정 표식을 먼저 확인한다.** 루트 폴더의 주인이 `SMOKE_USER_ID` 이고 그 아래에 `__smoke_account__` 폴더가 있어야 진행한다. 아니면 요청 2개만 보내고 멈추고, 그 응답 본문은 공개 로그에 남기지 않는다. +- **조회가 하나라도 실패하면 쓰기를 시작하지 않는다.** 서버가 불안정할 때 쓰기를 더하지 않는다. +- **지우는 것은 스모크 흔적뿐이다.** 이번 실행에서 만든 것과, 이름이 `__smoke_run_` 으로 시작하는 지난 실행의 흔적만 지운다. +- **요청 수와 시간에 상한이 있다.** 한 번 실행에 80개, 200초를 넘으면 멈춘다. 재시도는 GET 만, 연결 실패와 5xx, 429 에서 최대 3번이다. 쓰기는 재시도하지 않는다. +- **쓰기가 있는 실행은 대상별로 한 줄로 선다.** 조회만 하는 매시 실행은 따로 돌아서 서로 밀어내지 않는다. +- **리다이렉트를 따라가지 않고, 토큰은 https 로만 보낸다.** + +### 넣지 않은 API 와 이유 + +코드를 main 과 develop 두 버전으로 따라가서 호출만으로 흔적이 남는 것은 뺐다. + +| API | 뺀 이유 | +|---|---| +| `GET /api/users` | 로그인 미션 기록, 포인트와 마지막 접속 시각 갱신이 같이 돈다. 관리자 DAU 집계에도 잡힌다 | +| `GET /api/learning-reports` | 캐시가 없으면 OpenAI 를 부른다 | +| `GET /api/achievements` | 조건이 맞으면 업적 행을 INSERT 한다 | +| 문제 등록과 삭제 | 삭제해도 XP 와 미션 기록, develop 의 리마인더 행이 남는다 | +| 태그 만들기 | main 에서 지운 태그와 같은 이름으로 다시 만들면 유니크 인덱스에 걸린다 | +| 알림 붙인 복습노트 | Quartz 작업이 생기고, main 은 복습노트를 지워도 그 작업을 지우지 않는다 | +| 스터디룸 가입 | 다른 멤버의 피드에 보이고, 탈퇴 로직이 외래키에 막힌다 | + +### 로컬에서 잰 결과 + +운영과 같은 main 버전과 develop 버전 서버를 로컬 격리 스택에 각각 띄워 잰 값이다. + +| 확인한 것 | 결과 | +|---|---| +| `read` 전후 전체 테이블 `CHECKSUM TABLE` | 두 버전 모두 바뀐 테이블 없음 | +| `full` 전후 전체 테이블 `CHECKSUM TABLE` | `folder`, `practice_note` 두 테이블만 바뀜 (스모크 계정의 소프트 삭제 행이 하나씩 늘어남) | +| RabbitMQ 큐 | 새 메시지 없음 | +| 앱 로그 | 스모크가 일부러 확인한 404 외에 WARN, ERROR 없음 | +| 표식 없는 계정 ID 로 `full` | 요청 2개 뒤 멈춤, 테이블 변화 없음 | +| 중간에 끊긴 실행의 흔적을 만들어 두고 `full` | 흔적 폴더와 복습노트를 지우고 통과 | + +그래도 남는 것은 이 정도다. + +- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- 스모크 계정 1명이 관리자 사용자 목록과 사용자 수에 잡힌다. +- 스모크 계정 자신의 Redis 캐시 키(`TAG_LIST`, `STREAK`, `LEARNING_REPORT_SUMMARY`)가 TTL 로 생긴다. +- Prometheus 의 사용자 행동 지표에 게스트 요청으로 잡힌다. 사용자 ID 태그가 없어서 따로 걸러낼 수는 없다. +- 서버 DB 가 이미 죽어 있으면 조회가 500 이 되어 기존 예외 처리대로 Discord 에러 알림이 나간다(5분 중복 억제). + +## 처음 설정하는 순서 + +1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다. +2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 서버에 넘기는 그 키다(`ci-dev.yml`, `ci-prod.yml`). 스모크가 같은 키로 토큰을 만들기 때문에 따로 등록할 것이 없다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. +3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다. +4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다. +5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다. + +JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된다. 스모크용으로 값을 복사해 두면 한쪽만 남아 서버는 멀쩡한데 알림만 계속 울린다. +계정 ID 는 키를 바꿔도 그대로 쓴다. +이 키는 서버 서명 키 그 자체라 관리자 토큰도 만들 수 있으니, `main` 에서만 쓸 수 있는 GitHub Environment 에 두는 편이 더 안전하다. + +## 실패했을 때 읽는 법 + +| 실패 내용 | 가능한 원인 | +|---|---| +| Cloudflare 가 원 서버에 닿지 못했습니다 (502) | 맥미니, nginx, 공유기, 인터넷 중 하나가 죽었다 | +| Cloudflare 가 요청을 막았습니다 | Cloudflare 보안 설정이 GitHub 러너 IP 를 막았다. 서버 상태는 알 수 없다 | +| 리다이렉트 응답, JSON 이 아닌 응답 | 점검 모드가 켜져 있거나 nginx 설정이 바뀌었다 | +| 토큰이 거절됐습니다 `1009` | 시크릿이 서버 서명 키와 다르다 (develop 이후) | +| 토큰이 거절됐습니다 `1005` | 시크릿이 다르거나(main 은 서명이 틀려도 1005) 시계가 어긋났다 | +| 토큰이 거절됐습니다 `1007` | 시크릿이 다르거나 서버의 Redis 블랙리스트 조회가 실패했다 | +| 앱이 정상 응답하지 않습니다 (5xx) | 앱 내부 오류. DB 연결을 먼저 본다 | +| 루트 폴더에 표식 폴더가 없습니다 | `SMOKE_USER_ID` 가 스모크 계정이 아니다 | +| 응답 모양이 예상과 다릅니다 | 배포된 API 의 응답 형태가 바뀌었다. 앱 파싱도 깨졌을 수 있다 | +| 삭제 후에도 조회됩니다 | 삭제가 반영되지 않았다. 다음 실행이 흔적을 다시 지운다 | + +## 로컬에서 돌리기 + +종료 코드는 0 통과, 1 실패, 2 설정 오류다. + +```bash +# 닿는지만 +SMOKE_BASE_URL=https://ono-prod.seungminki.shop SMOKE_LEVEL=reach python3 scripts/smoke/smoke_test.py + +# 스크립트 자체 테스트 42개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한까지 본다) +python3 -m unittest discover -s scripts/smoke -p 'test_*.py' +``` + +## 주의 + +- 공개 레포라 Actions 로그와 실행 요약이 공개된다. 토큰은 마스킹하고, 요약에는 계정 ID 외의 값을 남기지 않는다. +- GitHub 는 공개 레포에 60일 동안 활동이 없으면 `schedule` 을 멈춘다. 그때는 Actions 화면에서 다시 켠다. diff --git a/scripts/smoke/smoke_test.py b/scripts/smoke/smoke_test.py new file mode 100644 index 00000000..d09580b1 --- /dev/null +++ b/scripts/smoke/smoke_test.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +"""OnO 스모크 테스트. + +배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. +컨테이너 healthcheck(actuator/health)는 프로세스가 떴는지만 알려 주고, +Cloudflare 와 nginx, JWT 서명 키, DB 조회는 거치지 않는다. 그 사이를 여기서 본다. + +확인은 세 단계로 나뉜다. 뒤 단계는 앞 단계를 포함한다. + + reach 토큰 없이 한 번 불러서 앱까지 닿는지 본다. + read 스모크 전용 게스트 계정으로 앱 화면들이 쓰는 조회 API 를 부른다. + full read 에 더해, 스모크 계정 안에서 폴더와 복습노트를 만들고 조회하고 지운다. + +운영 서버에 지장을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. +각 API 를 고른 근거는 README.md 에 있다. + +- 보낼 수 있는 요청은 ALLOWED_REQUESTS 에 적힌 메서드와 경로뿐이다. 그 밖의 요청은 보내기 전에 막는다. + GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 접속 시각 갱신, DAU 집계가 같이 돌아서 넣지 않았다. +- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. +- 지우는 것은 이번 실행에서 만든 것과, 이름이 RUN_PREFIX 로 시작하는 지난 실행의 흔적뿐이다. +- 스모크 계정 루트 폴더 아래에 MARKER_FOLDER_NAME 폴더가 없으면 스모크 계정이 아니라고 보고 + 더 이상 요청하지 않는다. SMOKE_USER_ID 를 잘못 넣어 실사용자 계정을 건드리는 일을 막는다. +- 한 번 실행에 보내는 요청 수는 MAX_REQUESTS 를 넘지 않고, 전체 시간은 DEADLINE_SECONDS 를 넘지 않는다. + 재시도도 여기에 포함된다. 서버가 느릴 때 스모크 테스트가 요청을 오래 붙들고 있지 않게 한다. +- 조회가 하나라도 실패하면 쓰기 흐름을 시작하지 않는다. 서버가 불안정할 때 쓰기를 더하지 않는다. +- 리다이렉트를 따라가지 않는다. urllib 은 기본으로 3xx 를 따라가면서 Authorization 헤더까지 + 옮기기 때문에, 따라가면 토큰이 다른 호스트로 샌다. +- 토큰은 https 이거나 로컬 주소일 때만 보낸다. + +환경 변수 + SMOKE_BASE_URL 필수. 예: https://ono-prod.seungminki.shop + SMOKE_LEVEL reach | read | full. 기본 read + SMOKE_ACCESS_TOKEN_SECRET 서버의 jwt.accessToken.secret 과 같은 Base64 값 + SMOKE_USER_ID bootstrap 으로 만든 스모크 게스트 계정 ID + + 시크릿이 없으면 reach 만 한다. 시크릿만 있고 계정 ID 가 없으면 존재하지 않는 사용자 ID 0 으로 + 인증과 count 조회 한 번만 확인한다. + +명령 + python3 smoke_test.py 확인 + python3 smoke_test.py bootstrap 스모크 게스트 계정을 한 번 만든다 (Discord 가입 알림이 한 번 간다) + +종료 코드: 0 통과, 1 실패, 2 설정 오류 +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import os +import re +import secrets +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Callable, Mapping + +PROBE_PATH = "/api/problems/problemCount" + +# 인증 확인만 할 때 쓰는 존재하지 않는 사용자. +# JwtTokenFilter 는 서명과 블랙리스트만 보고 사용자 존재 여부는 보지 않으며, count 는 0 을 돌려준다. +NONEXISTENT_USER_ID = "0" +SMOKE_AUTHORITY = "ROLE_GUEST" +TOKEN_TTL_SECONDS = 300 + +MARKER_FOLDER_NAME = "__smoke_account__" +RUN_PREFIX = "__smoke_run_" + +REQUEST_TIMEOUT_SECONDS = 10 +MAX_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 3 +MAX_REQUESTS = 80 +DEADLINE_SECONDS = 200 +USER_AGENT = "OnO-SmokeTest/2.0" + +LEVELS = ("reach", "read", "full") +LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1") +KST = timezone(timedelta(hours=9)) + +# AuthErrorCase +AUTHENTICATION_FAILED = 1007 +ACCESS_TOKEN_EXPIRED = 1005 +INVALID_ACCESS_TOKEN = 1009 +# FolderErrorCase, PracticeNoteErrorCase +FOLDER_NOT_FOUND = 5001 +PRACTICE_NOTE_NOT_FOUND = 6001 + +WRITE_METHODS = ("POST", "PATCH", "DELETE") +SIGNUP_PATH = "/api/auth/signup/guest" + +# 보낼 수 있는 요청 전부. 경로는 쿼리스트링을 뺀 값과 통째로 맞아야 한다. +ALLOWED_REQUESTS = [ + ("GET", r"/api/problems/problemCount"), + ("GET", r"/api/folders"), + ("GET", r"/api/folders/root"), + ("GET", r"/api/folders/thumbnails/V2"), + ("GET", r"/api/folders/\d+"), + ("GET", r"/api/folders/\d+/subfolders/V2"), + ("GET", r"/api/problems/review-due"), + ("GET", r"/api/problems/user"), + ("GET", r"/api/problems/folder/\d+/V2"), + ("GET", r"/api/practiceNotes/thumbnail"), + ("GET", r"/api/practiceNotes/thumbnail/V2"), + ("GET", r"/api/practiceNotes/all"), + ("GET", r"/api/practiceNotes/\d+"), + ("GET", r"/api/problem-solves/user/count"), + ("GET", r"/api/study-room"), + ("GET", r"/api/tags"), + ("GET", r"/api/learning-calendar"), + ("GET", r"/api/learning-reports/summary"), + ("POST", r"/api/folders"), + ("PATCH", r"/api/folders"), + ("DELETE", r"/api/folders"), + ("POST", r"/api/practiceNotes"), + ("DELETE", r"/api/practiceNotes"), + ("POST", SIGNUP_PATH), +] + + +class SmokeAbort(Exception): + """더 진행하면 안 되는 상황. 남은 확인을 건너뛰고 정리만 한다.""" + + +@dataclass +class Response: + status: int | None + headers: Mapping[str, str] = field(default_factory=dict) + body: bytes = b"" + error: str | None = None + elapsed_ms: int = 0 + attempts: int = 1 + + def json(self): + try: + return json.loads(self.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + def data(self): + body = self.json() + return body.get("data") if isinstance(body, dict) else None + + def error_code(self): + body = self.json() + return body.get("errorCode") if isinstance(body, dict) else None + + def snippet(self) -> str: + text = self.body.decode("utf-8", errors="replace").strip().replace("\n", " ") + return text[:120] + + +@dataclass +class CheckResult: + name: str + passed: bool + detail: str + elapsed_ms: int = 0 + skipped: bool = False + + +# --- 토큰 --------------------------------------------------------------------- + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def mint_access_token(secret_b64: str, now: int, user_id: str = NONEXISTENT_USER_ID) -> str: + """JwtTokenizer.createAccessToken 과 같은 모양의 HS256 토큰을 만든다. + + 서버는 jwt.accessToken.secret 을 Base64 로 디코딩한 바이트를 HMAC 키로 쓴다. + """ + key = base64.b64decode(secret_b64.strip(), validate=True) + header = {"alg": "HS256"} + payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} + signing_input = ( + _b64url(json.dumps(header, separators=(",", ":")).encode()) + + "." + + _b64url(json.dumps(payload, separators=(",", ":")).encode()) + ) + signature = hmac.new(key, signing_input.encode("ascii"), hashlib.sha256).digest() + return signing_input + "." + _b64url(signature) + + +def subject_of(token: str) -> str | None: + """서버가 발급한 토큰에서 sub 를 꺼낸다. 서명은 검증하지 않는다(bootstrap 에서 계정 ID 를 알아낼 때만 쓴다).""" + parts = token.removeprefix("Bearer ").strip().split(".") + if len(parts) != 3: + return None + try: + payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))) + except (binascii.Error, ValueError): + return None + sub = payload.get("sub") if isinstance(payload, dict) else None + return str(sub) if sub is not None else None + + +def _mask(value: str, env: Mapping[str, str]) -> None: + if env.get("GITHUB_ACTIONS") == "true" and value: + print(f"::add-mask::{value}") + + +# --- HTTP --------------------------------------------------------------------- + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +class Budget: + """한 번 실행에서 모든 Client 가 함께 쓰는 요청 수와 시간 상한.""" + + def __init__(self, limit: int | None = None, deadline_seconds: float | None = None): + self.limit = MAX_REQUESTS if limit is None else limit + self.deadline = time.monotonic() + (DEADLINE_SECONDS if deadline_seconds is None else deadline_seconds) + self.sent = 0 + + def spend(self) -> None: + if self.sent >= self.limit: + raise SmokeAbort(f"한 번 실행의 요청 상한({self.limit}개)에 닿아 멈췄습니다") + if time.monotonic() >= self.deadline: + raise SmokeAbort("실행 시간 상한을 넘어 멈췄습니다. 서버 응답이 매우 느릴 수 있습니다") + self.sent += 1 + + +class Client: + """이 스크립트가 서버에 요청을 보내는 유일한 통로.""" + + def __init__(self, base_url: str, sleep: Callable[[float], None], budget: Budget, token: str | None = None, + allow_writes: bool = False, allow_signup: bool = False): + self.base_url = base_url.rstrip("/") + self.sleep = sleep + self.budget = budget + self.token = token + self.allow_writes = allow_writes + self.allow_signup = allow_signup + + def request(self, method: str, path: str, body=None, query: Mapping[str, object] | None = None, + auth: bool = True) -> Response: + method = method.upper() + if not any(m == method and re.fullmatch(p, path) for m, p in ALLOWED_REQUESTS): + raise SmokeAbort(f"허용 목록에 없는 요청이라 보내지 않았습니다: {method} {path}") + if path == SIGNUP_PATH: + if not self.allow_signup: + raise SmokeAbort("게스트 가입은 bootstrap 에서만 보낼 수 있습니다") + elif method in WRITE_METHODS and not self.allow_writes: + raise SmokeAbort(f"쓰기 요청은 full 단계에서만 보낼 수 있습니다: {method} {path}") + + url = self.base_url + path + if query: + url += "?" + urllib.parse.urlencode(query) + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + if auth and self.token: + headers["Authorization"] = self.token + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + res = self._send(method, url, headers, data) + attempts = 1 + # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. + while method == "GET" and _should_retry(res) and attempts < MAX_ATTEMPTS: + self.sleep(RETRY_DELAY_SECONDS) + res = self._send(method, url, headers, data) + attempts += 1 + res.attempts = attempts + return res + + def _send(self, method: str, url: str, headers: Mapping[str, str], data: bytes | None) -> Response: + self.budget.spend() + request = urllib.request.Request(url, data=data, method=method, headers=dict(headers)) + started = time.monotonic() + try: + with _OPENER.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as res: + return Response(res.status, dict(res.headers), res.read(), elapsed_ms=_ms(started)) + except urllib.error.HTTPError as e: + with e: + return Response(e.code, dict(e.headers or {}), e.read(), elapsed_ms=_ms(started)) + except (urllib.error.URLError, TimeoutError, OSError) as e: + return Response(None, error=str(getattr(e, "reason", e)), elapsed_ms=_ms(started)) + + +def _ms(started: float) -> int: + return int((time.monotonic() - started) * 1000) + + +def _should_retry(res: Response) -> bool: + return res.status is None or res.status == 429 or res.status >= 500 + + +def _lower_headers(res: Response) -> dict[str, str]: + return {k.lower(): v for k, v in res.headers.items()} + + +def describe_unexpected(res: Response) -> str: + if res.status is None: + return f"서버에 닿지 않았습니다 ({res.error}, {res.attempts}번 시도)" + headers = _lower_headers(res) + server = headers.get("server", "").lower() + if 300 <= res.status < 400: + return (f"리다이렉트 응답입니다 (HTTP {res.status}, Location: {headers.get('location', '-')}). " + "따라가지 않았습니다. 점검 모드나 nginx 설정을 확인해야 합니다") + if "cf-mitigated" in headers or (res.status == 403 and "cloudflare" in server and res.json() is None): + return f"Cloudflare 가 요청을 막았습니다 (HTTP {res.status}). 앱까지 가지 않아서 서버 상태는 알 수 없습니다" + if res.status >= 500 and "cloudflare" in server and res.json() is None: + # 원 서버 응답이면 nginx 나 앱이 만든 본문이 온다. Cloudflare 가 직접 만든 5xx 는 + # 원 서버(맥미니 nginx)까지 닿지 못했다는 뜻이라 앱 오류와 구분해서 알린다. + return (f"Cloudflare 가 원 서버에 닿지 못했습니다 (HTTP {res.status}, {res.attempts}번 시도). " + "서버나 nginx 가 꺼져 있거나 네트워크가 끊겼을 수 있습니다") + if res.status >= 500: + return f"앱이 정상 응답하지 않습니다 (HTTP {res.status}, {res.attempts}번 시도): {res.snippet()}" + if res.json() is None: + return f"JSON 이 아닌 응답입니다 (HTTP {res.status}). nginx 오류 페이지나 점검 페이지일 수 있습니다: {res.snippet()}" + if res.status == 401: + return describe_token_rejection(res) + return f"예상과 다른 응답입니다 (HTTP {res.status}): {res.snippet()}" + + +def describe_token_rejection(res: Response) -> str: + # 서버 버전에 따라 같은 원인에도 코드가 다르게 나와서 가능한 원인을 같이 적는다. + # main(2026-07-01)은 서명이 틀려도 1005 를 주고, develop 은 1009 를 준다. + # 1007 은 블랙리스트 조회(Redis) 실패처럼 토큰과 무관한 예외에서도 나온다. + causes = { + INVALID_ACCESS_TOKEN: "SMOKE_ACCESS_TOKEN_SECRET 이 서버의 JWT 서명 키와 다를 수 있습니다", + ACCESS_TOKEN_EXPIRED: "서명 키가 다르거나(구버전 서버는 이때도 1005 를 줍니다) 러너와 서버의 시계가 어긋났을 수 있습니다", + AUTHENTICATION_FAILED: "서명 키가 다르거나 서버의 Redis(토큰 블랙리스트 조회)에 문제가 있을 수 있습니다", + } + code = res.error_code() + return f"토큰이 거절됐습니다 (errorCode {code}). {causes.get(code, res.snippet())}" + + +# --- 판정 도우미 ----------------------------------------------------------------- + +def is_count(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def is_cursor_page(value) -> bool: + return isinstance(value, dict) and isinstance(value.get("content"), list) and "hasNext" in value + + +def has_keys(*keys: str) -> Callable[[object], bool]: + return lambda value: isinstance(value, dict) and all(k in value for k in keys) + + +def is_list(value) -> bool: + return isinstance(value, list) + + +def is_dict(value) -> bool: + return isinstance(value, dict) + + +# --- 확인 ----------------------------------------------------------------------- + +class SmokeRun: + def __init__(self, env: Mapping[str, str], sleep: Callable[[float], None], clock: Callable[[], float]): + self.env = env + self.sleep = sleep + self.clock = clock + self.results: list[CheckResult] = [] + self.budget = Budget() + self.base_url = env.get("SMOKE_BASE_URL", "").strip() + self.level = env.get("SMOKE_LEVEL", "").strip() or "read" + self.secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() + self.user_id = env.get("SMOKE_USER_ID", "").strip() + stamp = datetime.fromtimestamp(clock(), timezone.utc).strftime("%Y%m%dT%H%M%S") + self.run_tag = f"{RUN_PREFIX}{stamp}_{secrets.token_hex(2)}" + + # 결과 기록 + def ok(self, name: str, detail: str, res: Response | None = None) -> None: + self.results.append(CheckResult(name, True, detail, res.elapsed_ms if res else 0)) + + def fail(self, name: str, detail: str, res: Response | None = None) -> None: + self.results.append(CheckResult(name, False, detail, res.elapsed_ms if res else 0)) + + def skip(self, name: str, detail: str, passed: bool = True) -> None: + self.results.append(CheckResult(name, passed, detail, skipped=True)) + + def expect(self, name: str, res: Response, status: int, + validate: Callable[[object], bool] | None = None) -> bool: + if res.status == status and (validate is None or validate(res.data())): + self.ok(name, f"HTTP {status}", res) + return True + if res.status == status: + self.fail(name, f"응답 모양이 예상과 다릅니다 (HTTP {status}): {res.snippet()}", res) + else: + self.fail(name, describe_unexpected(res), res) + return False + + # 전체 흐름 + def validate_config(self) -> str | None: + if not self.base_url.startswith(("https://", "http://")): + return "SMOKE_BASE_URL 이 없거나 http(s):// 로 시작하지 않습니다" + if self.level not in LEVELS: + return f"SMOKE_LEVEL 은 {', '.join(LEVELS)} 중 하나여야 합니다: {self.level}" + host = urllib.parse.urlsplit(self.base_url).hostname or "" + if self.secret and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: + return "토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다" + if self.user_id and not re.fullmatch(r"[0-9]+", self.user_id): + return "SMOKE_USER_ID 는 숫자여야 합니다" + if self.secret: + try: + base64.b64decode(self.secret, validate=True) + except (binascii.Error, ValueError): + return "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다" + return None + + def execute(self) -> None: + if not self.check_reachable(Client(self.base_url, self.sleep, self.budget)): + self.skip("나머지 확인", "앱까지 닿지 않아서 요청을 더 보내지 않았습니다", passed=False) + return + if self.level == "reach": + return + if not self.secret: + self.skip("인증과 조회", "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다") + return + if not self.user_id: + self.check_probe_with_nonexistent_user() + self.skip("스모크 계정 확인", "SMOKE_USER_ID 가 없어 조회 세트와 쓰기 흐름을 건너뛰었습니다") + return + + token = "Bearer " + mint_access_token(self.secret, int(self.clock()), self.user_id) + _mask(token, self.env) + client = Client(self.base_url, self.sleep, self.budget, token=token, allow_writes=(self.level == "full")) + root = self.check_smoke_account(client) + if root is None: + return + self.check_reads(client, root) + if self.level != "full": + return + if any(not r.passed for r in self.results): + self.skip("쓰기 흐름", "조회가 실패해서 쓰기를 시작하지 않았습니다") + return + self.check_writes(client, root) + + def check_reachable(self, client: Client) -> bool: + name = "앱까지 닿는가 (토큰 없이 401)" + res = client.request("GET", PROBE_PATH, auth=False) + if res.status == 401 and res.error_code() == AUTHENTICATION_FAILED: + self.ok(name, f"HTTP 401, errorCode {AUTHENTICATION_FAILED}", res) + return True + if res.status == 200: + self.fail(name, "토큰 없이 200 이 나왔습니다. 인증 설정이 풀렸는지 확인이 필요합니다", res) + else: + self.fail(name, describe_unexpected(res), res) + return False + + def check_probe_with_nonexistent_user(self) -> None: + token = "Bearer " + mint_access_token(self.secret, int(self.clock())) + _mask(token, self.env) + client = Client(self.base_url, self.sleep, self.budget, token=token) + self.expect("인증과 DB 조회가 되는가 (없는 사용자 토큰으로 count)", client.request("GET", PROBE_PATH), 200, is_count) + + def check_smoke_account(self, client: Client) -> dict | None: + name = "스모크 계정 확인 (루트 폴더의 표식 폴더)" + res = client.request("GET", "/api/folders/root") + data = res.data() + if res.status != 200 or not has_keys("folderId", "subFolderList")(data): + # 계정이 스모크 계정인지 모르는 상태라, 200 응답 본문은 로그와 알림에 싣지 않는다(공개 레포). + detail = "루트 폴더 응답 모양이 예상과 다릅니다" if res.status == 200 else describe_unexpected(res) + self.fail(name, detail, res) + self.skip("나머지 확인", "스모크 계정을 확인하지 못해 요청을 더 보내지 않았습니다", passed=False) + return None + if "userId" in data and str(data["userId"]) != self.user_id: + self.fail(name, "루트 폴더의 주인이 SMOKE_USER_ID 와 다릅니다. 요청을 더 보내지 않았습니다", res) + self.skip("나머지 확인", "다른 계정의 데이터일 수 있어 멈췄습니다", passed=False) + return None + names = [f.get("folderName") for f in data["subFolderList"] if isinstance(f, dict)] + if MARKER_FOLDER_NAME not in names: + self.fail(name, f"루트 폴더에 {MARKER_FOLDER_NAME} 폴더가 없습니다. " + "SMOKE_USER_ID 가 스모크 계정이 아닐 수 있어 멈췄습니다", res) + self.skip("나머지 확인", "실사용자 계정일 수 있어 요청을 더 보내지 않았습니다", passed=False) + return None + self.ok(name, "HTTP 200, 표식 폴더 있음", res) + return data + + def check_reads(self, client: Client, root: dict) -> None: + root_id = root["folderId"] + now = datetime.fromtimestamp(self.clock(), KST) + reads = [ + ("폴더 목록", "/api/folders", None, is_list), + ("폴더 썸네일", "/api/folders/thumbnails/V2", None, is_cursor_page), + ("폴더 단건", f"/api/folders/{root_id}", None, has_keys("folderId", "folderName")), + ("하위 폴더", f"/api/folders/{root_id}/subfolders/V2", None, is_cursor_page), + ("오답노트 수", PROBE_PATH, None, is_count), + ("복습할 문제", "/api/problems/review-due", None, has_keys("dueCount", "overdueCount", "problems")), + ("내 오답노트", "/api/problems/user", None, is_list), + ("폴더 안 오답노트", f"/api/problems/folder/{root_id}/V2", None, is_cursor_page), + ("복습노트 썸네일", "/api/practiceNotes/thumbnail/V2", None, is_cursor_page), + ("복습노트 전체", "/api/practiceNotes/all", None, is_list), + ("풀이 기록 수", "/api/problem-solves/user/count", None, is_count), + ("내 스터디룸", "/api/study-room", None, is_list), + ("태그", "/api/tags", None, is_list), + ("학습 캘린더", "/api/learning-calendar", {"year": now.year, "month": now.month}, + has_keys("year", "month", "records")), + ("학습 리포트 요약", "/api/learning-reports/summary", None, is_dict), + ] + for label, path, query, validate in reads: + self.expect(f"조회: {label}", client.request("GET", path, query=query), 200, validate) + + # 쓰기 흐름 ------------------------------------------------------------------- + + def check_writes(self, client: Client, root: dict) -> None: + created_folders: list[int] = [] + created_notes: list[int] = [] + try: + self.clean_leftovers(client, root) + self.folder_flow(client, root["folderId"], created_folders) + self.practice_note_flow(client, created_notes) + except SmokeAbort as e: + self.fail("쓰기 흐름 중단", str(e)) + finally: + self.cleanup(client, created_folders, created_notes) + + def clean_leftovers(self, client: Client, root: dict) -> None: + """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트를 먼저 지운다.""" + name = "지난 실행 흔적 정리" + folder_ids = [f["folderId"] for f in root["subFolderList"] + if isinstance(f, dict) and str(f.get("folderName", "")).startswith(RUN_PREFIX)] + res = client.request("GET", "/api/practiceNotes/thumbnail") + if res.status != 200 or not is_list(res.data()): + self.fail(name, describe_unexpected(res) if res.status != 200 else "복습노트 목록 모양이 예상과 다릅니다", res) + raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") + note_ids = [n["practiceNoteId"] for n in res.data() + if isinstance(n, dict) and str(n.get("practiceTitle", "")).startswith(RUN_PREFIX)] + if not folder_ids and not note_ids: + self.ok(name, "남은 흔적 없음", res) + return + failed = [] + if folder_ids and self.delete_folders(client, folder_ids).status != 200: + failed.append(f"폴더 {folder_ids}") + if note_ids and self.delete_notes(client, note_ids).status != 200: + failed.append(f"복습노트 {note_ids}") + if failed: + self.fail(name, f"지우지 못했습니다: {', '.join(failed)}") + else: + self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개를 지웠습니다") + + def folder_flow(self, client: Client, root_id: int, created: list[int]) -> None: + folder_name = self.run_tag + res = client.request("POST", "/api/folders", {"folderName": folder_name, "parentFolderId": root_id}) + if not self.expect("쓰기: 폴더 만들기", res, 200, is_count): + raise SmokeAbort("폴더를 만들지 못해 쓰기 흐름을 멈췄습니다") + folder_id = res.data() + created.append(folder_id) + + def created_under_root(v) -> bool: + parent = v.get("parentFolder") if isinstance(v, dict) else None + return (isinstance(v, dict) and v.get("folderName") == folder_name + and isinstance(parent, dict) and parent.get("folderId") == root_id) + + self.expect("쓰기: 만든 폴더 조회", client.request("GET", f"/api/folders/{folder_id}"), 200, created_under_root) + + renamed = folder_name + "_r" + res = client.request("PATCH", "/api/folders", {"folderId": folder_id, "folderName": renamed}) + if self.expect("쓰기: 폴더 이름 바꾸기", res, 200): + self.expect("쓰기: 바뀐 이름 조회", client.request("GET", f"/api/folders/{folder_id}"), 200, + lambda v: isinstance(v, dict) and v.get("folderName") == renamed) + + if self.expect("쓰기: 폴더 지우기", self.delete_folders(client, [folder_id]), 200): + res = client.request("GET", f"/api/folders/{folder_id}") + if res.status == 404 and res.error_code() == FOLDER_NOT_FOUND: + self.ok("쓰기: 지운 폴더가 안 보이는가", f"HTTP 404, errorCode {FOLDER_NOT_FOUND}", res) + created.remove(folder_id) + else: + self.fail("쓰기: 지운 폴더가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + + def practice_note_flow(self, client: Client, created: list[int]) -> None: + title = self.run_tag + # practiceNotification 을 보내지 않는다. 보내면 Quartz 알림 작업이 등록되고, + # 운영 main 버전은 복습노트를 지워도 그 작업을 지우지 않는다. + res = client.request("POST", "/api/practiceNotes", {"practiceTitle": title, "problemIdList": []}) + if not self.expect("쓰기: 복습노트 만들기", res, 201, is_count): + raise SmokeAbort("복습노트를 만들지 못해 쓰기 흐름을 멈췄습니다") + note_id = res.data() + created.append(note_id) + + self.expect("쓰기: 만든 복습노트 조회", client.request("GET", f"/api/practiceNotes/{note_id}"), 200, + lambda v: isinstance(v, dict) and v.get("practiceTitle") == title + and v.get("practiceNotification") is None) + + if self.expect("쓰기: 복습노트 지우기", self.delete_notes(client, [note_id]), 200): + res = client.request("GET", f"/api/practiceNotes/{note_id}") + if res.status == 404 and res.error_code() == PRACTICE_NOTE_NOT_FOUND: + self.ok("쓰기: 지운 복습노트가 안 보이는가", f"HTTP 404, errorCode {PRACTICE_NOTE_NOT_FOUND}", res) + created.remove(note_id) + else: + self.fail("쓰기: 지운 복습노트가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + + @staticmethod + def delete_folders(client: Client, folder_ids: list[int]) -> Response: + return client.request("DELETE", "/api/folders", {"deleteFolderIdList": folder_ids}) + + @staticmethod + def delete_notes(client: Client, note_ids: list[int]) -> Response: + return client.request("DELETE", "/api/practiceNotes", {"deletePracticeIdList": note_ids}) + + def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: + """흐름이 중간에 멈춰 남은 것을 지운다. 여기서도 못 지우면 다음 실행의 흔적 정리가 지운다.""" + if not folders and not notes: + return + leftovers = [] + try: + if folders and self.delete_folders(client, folders).status not in (200, 404): + leftovers.append(f"폴더 {folders}") + if notes and self.delete_notes(client, notes).status not in (200, 404): + leftovers.append(f"복습노트 {notes}") + except SmokeAbort as e: + leftovers.append(str(e)) + if leftovers: + self.fail("마무리 정리", f"지우지 못했습니다: {', '.join(leftovers)}. 다음 실행이 다시 지웁니다") + else: + self.ok("마무리 정리", "중간에 남은 것을 지웠습니다") + + +# --- bootstrap ------------------------------------------------------------------ + +def bootstrap(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep) -> int: + """스모크 게스트 계정을 만들고 표식 폴더를 단다. 가입할 때 Discord 알림이 한 번 간다.""" + base_url = env.get("SMOKE_BASE_URL", "").strip() + if not base_url.startswith(("https://", "http://")): + print("SMOKE_BASE_URL 이 없거나 http(s):// 로 시작하지 않습니다", file=sys.stderr) + return 2 + if env.get("SMOKE_USER_ID", "").strip(): + print("SMOKE_USER_ID 가 이미 있습니다. 계정을 또 만들지 않도록 멈췄습니다", file=sys.stderr) + return 2 + + client = Client(base_url, sleep, Budget(limit=10), allow_signup=True) + try: + res = client.request("POST", SIGNUP_PATH, auth=False) + data = res.data() + token = data.get("accessToken") if isinstance(data, dict) else None + if res.status != 200 or not token: + print(f"게스트 가입 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + _mask(token, env) + if isinstance(data, dict) and data.get("refreshToken"): + _mask(data["refreshToken"], env) + user_id = subject_of(token) + if not user_id: + print("발급된 토큰에서 계정 ID 를 읽지 못했습니다", file=sys.stderr) + return 1 + + client.token = token if token.startswith("Bearer ") else "Bearer " + token + client.allow_writes = True + res = client.request("GET", "/api/folders/root") + root = res.data() + if res.status != 200 or not has_keys("folderId", "subFolderList")(root): + print(f"루트 폴더 조회 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + res = client.request("POST", "/api/folders", {"folderName": MARKER_FOLDER_NAME, "parentFolderId": root["folderId"]}) + if res.status != 200: + print(f"표식 폴더 만들기 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + except SmokeAbort as e: + print(str(e), file=sys.stderr) + return 1 + + print(f"스모크 계정을 만들었습니다. SMOKE_USER_ID = {user_id}") + summary = env.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as f: + f.write(f"## 스모크 계정 만들기\n\n- 대상: `{base_url}`\n- 계정 ID: `{user_id}`\n" + "- 이 값을 저장소 Secrets 의 `SMOKE_PROD_USER_ID` (dev 는 `SMOKE_DEV_USER_ID`) 에 넣으세요.\n") + return 0 + + +# --- 실행 ----------------------------------------------------------------------- + +def run(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.time) -> int: + smoke = SmokeRun(env, sleep, clock) + problem = smoke.validate_config() + if problem: + print(problem, file=sys.stderr) + return 2 + + print(f"대상: {smoke.base_url} (단계: {smoke.level})") + try: + smoke.execute() + except SmokeAbort as e: + smoke.fail("실행 중단", str(e)) + + failed = [r for r in smoke.results if not r.passed] + for r in smoke.results: + mark = "건너뜀" if r.skipped else ("통과" if r.passed else "실패") + elapsed = f" ({r.elapsed_ms}ms)" if r.elapsed_ms else "" + print(f"[{mark}] {r.name}{elapsed}: {r.detail}") + _write_step_summary(env, smoke, failed) + _write_outputs(env, smoke, failed) + + print(f"보낸 요청: {smoke.budget.sent}개 (상한 {smoke.budget.limit}개)") + print("스모크 테스트 실패" if failed else "스모크 테스트 통과") + return 1 if failed else 0 + + +def _write_step_summary(env: Mapping[str, str], smoke: SmokeRun, failed: list[CheckResult]) -> None: + path = env.get("GITHUB_STEP_SUMMARY") + if not path: + return + lines = [ + "## 스모크 테스트", + "", + f"- 대상: `{smoke.base_url}`, 단계: `{smoke.level}`, 보낸 요청: {smoke.budget.sent}개", + f"- 결과: {'❌ 실패 ' + str(len(failed)) + '건' if failed else '✅ 통과'}", + "", + "| 확인 | 결과 | 응답 시간 | 내용 |", + "|---|---|---:|---|", + ] + for r in smoke.results: + mark = "건너뜀" if r.skipped else ("✅" if r.passed else "❌") + elapsed = f"{r.elapsed_ms}ms" if r.elapsed_ms else "-" + lines.append(f"| {r.name} | {mark} | {elapsed} | {r.detail.replace('|', '/')} |") + with open(path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +def _write_outputs(env: Mapping[str, str], smoke: SmokeRun, failed: list[CheckResult]) -> None: + path = env.get("GITHUB_OUTPUT") + if not path: + return + first = f"{failed[0].name}: {failed[0].detail}" if failed else "" + with open(path, "a", encoding="utf-8") as f: + f.write(f"failed={len(failed)}\n") + f.write(f"first_failure={first.replace(chr(10), ' ')[:300]}\n") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "bootstrap": + sys.exit(bootstrap(os.environ)) + if len(sys.argv) > 1: + print(f"알 수 없는 명령: {sys.argv[1]}", file=sys.stderr) + sys.exit(2) + sys.exit(run(os.environ)) diff --git a/scripts/smoke/test_smoke_test.py b/scripts/smoke/test_smoke_test.py new file mode 100644 index 00000000..4a92b691 --- /dev/null +++ b/scripts/smoke/test_smoke_test.py @@ -0,0 +1,591 @@ +"""smoke_test.py 자체 테스트. + +운영 서버에 요청을 보내기 전에 스크립트가 틀리지 않았는지 확인하려고 둔다. +스모크 테스트 워크플로가 실제 요청보다 먼저 이 테스트를 돌린다. + +OnO 서버가 스모크 테스트에 응답하는 방식을 흉내 내는 가짜 서버를 띄우고, 받은 요청을 전부 기록해서 +"허용한 요청만, 정해진 횟수 안에서, 스모크 흔적만 지우는지"까지 확인한다. + +실행: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' -v +""" + +import base64 +import hashlib +import hmac +import json +import os +import re +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import smoke_test # noqa: E402 + +SERVER_SECRET = base64.b64encode(b"s" * 64).decode() +OTHER_SECRET = base64.b64encode(b"x" * 64).decode() +NOW = 1_800_000_000 +SMOKE_USER = "42" +REAL_USER = "7" + + +def _b64url_decode(part: str) -> bytes: + return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)) + + +def verify_token(token: str, secret_b64: str): + """서버의 JwtTokenFilter 가 하는 일(서명 검증 후 클레임 읽기)을 흉내 낸다.""" + header, payload, signature = token.split(".") + key = base64.b64decode(secret_b64) + expected = hmac.new(key, f"{header}.{payload}".encode(), hashlib.sha256).digest() + if not hmac.compare_digest(expected, _b64url_decode(signature)): + return None + return json.loads(_b64url_decode(payload)) + + +def issued_token(user_id: str) -> str: + """서버가 게스트 가입 때 발급하는 토큰 모양. 앞에 Bearer 가 붙어 있다.""" + return "Bearer " + smoke_test.mint_access_token(SERVER_SECRET, NOW, user_id) + + +class FakeOnO: + """스모크 테스트가 부르는 API 만 흉내 내는 OnO 서버. + + 사용자마다 루트 폴더와 하위 폴더, 복습노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + override(method, path_regex) 로 특정 요청의 응답을 바꿔 실패 상황을 만든다. + """ + + def __init__(self): + self.requests = [] + self.overrides = [] + self.lock = threading.Lock() + self.next_id = 100 + self.users = {} + self.add_user(SMOKE_USER, marker=True) + self.add_user(REAL_USER, marker=False, extra_folders=["수학", "__smoke_run_looks_like_but_real_user"]) + outer = self + + class Handler(BaseHTTPRequestHandler): + def _handle(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + body = json.loads(raw) if raw else None + outer.requests.append((self.command, self.path, dict(self.headers), body)) + status, headers, payload = outer.dispatch(self.command, self.path, dict(self.headers), body) + headers = dict(headers) + server = headers.pop("Server", None) + if server: + self.version_string = lambda: server + try: + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): + pass + + do_GET = do_POST = do_PATCH = do_PUT = do_DELETE = _handle + + def log_message(self, *args): + pass + + self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}" + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + # 상태 + def new_id(self): + with self.lock: + self.next_id += 1 + return self.next_id + + def add_user(self, user_id, marker, extra_folders=()): + root = self.new_id() + folders = {root: {"folderId": root, "folderName": "책장", "parent": None, "deleted": False}} + names = ["공책"] + (["__smoke_account__"] if marker else []) + list(extra_folders) + for name in names: + fid = self.new_id() + folders[fid] = {"folderId": fid, "folderName": name, "parent": root, "deleted": False} + nid = self.new_id() + notes = {nid: {"practiceNoteId": nid, "practiceTitle": "복습 세트", "deleted": False}} + self.users[user_id] = {"root": root, "folders": folders, "notes": notes} + + def live_folder_names(self, user_id): + return sorted(f["folderName"] for f in self.users[user_id]["folders"].values() if not f["deleted"]) + + def live_note_titles(self, user_id): + return sorted(n["practiceTitle"] for n in self.users[user_id]["notes"].values() if not n["deleted"]) + + def override(self, method, path_regex, response, times=None): + self.overrides.append({"method": method, "path": path_regex, "response": response, "times": times}) + + def paths(self, method=None): + return [(m, p.split("?")[0]) for m, p, _, _ in self.requests if method is None or m == method] + + # 응답 + def dispatch(self, method, full_path, headers, body): + path = full_path.split("?")[0] + for o in self.overrides: + if o["method"] == method and re.fullmatch(o["path"], path) and o["times"] != 0: + if o["times"] is not None: + o["times"] -= 1 + resp = o["response"] + return resp(method, path, headers, body) if callable(resp) else resp + + if method == "POST" and path == "/api/auth/signup/guest": + uid = str(self.new_id()) + self.add_user(uid, marker=False) + return ok({"accessToken": issued_token(uid), "refreshToken": "refresh-" + uid}) + + auth = headers.get("Authorization") + if not auth: + return error(401, 1007) + claims = verify_token(auth.removeprefix("Bearer "), SERVER_SECRET) + if claims is None: + return error(401, 1009) + uid = claims["sub"] + if uid not in self.users: + if method == "GET" and path == "/api/problems/problemCount": + return ok(0) + return error(404, 5001) + user = self.users[uid] + folders, notes = user["folders"], user["notes"] + + def folder_view(f): + parent = folders.get(f["parent"]) + subs = [{"folderId": s["folderId"], "folderName": s["folderName"]} + for s in folders.values() if s["parent"] == f["folderId"] and not s["deleted"]] + return {"folderId": f["folderId"], "folderName": f["folderName"], "subFolderList": subs, "userId": int(uid), + "parentFolder": {"folderId": parent["folderId"], "folderName": parent["folderName"]} if parent else None} + + page = {"content": [], "hasNext": False, "nextCursor": None, "size": 20} + m = re.fullmatch(r"/api/folders/(\d+)", path) + if method == "GET": + if path == "/api/folders/root": + return ok(folder_view(folders[user["root"]])) + if m: + f = folders.get(int(m.group(1))) + return ok(folder_view(f)) if f and not f["deleted"] else error(404, 5001) + n = re.fullmatch(r"/api/practiceNotes/(\d+)", path) + if n: + note = notes.get(int(n.group(1))) + if not note or note["deleted"]: + return error(404, 6001) + return ok({"practiceNoteId": note["practiceNoteId"], "practiceTitle": note["practiceTitle"], + "practiceNotification": None}) + simple = { + "/api/folders": [folder_view(f) for f in folders.values() if not f["deleted"]], + "/api/folders/thumbnails/V2": page, + "/api/problems/problemCount": 0, + "/api/problems/review-due": {"dueCount": 0, "overdueCount": 0, "problems": []}, + "/api/problems/user": [], + "/api/practiceNotes/thumbnail": [{"practiceNoteId": x["practiceNoteId"], "practiceTitle": x["practiceTitle"]} + for x in notes.values() if not x["deleted"]], + "/api/practiceNotes/thumbnail/V2": page, + "/api/practiceNotes/all": [], + "/api/problem-solves/user/count": 0, + "/api/study-room": [], + "/api/tags": [], + "/api/learning-calendar": {"year": 2027, "month": 1, "records": []}, + "/api/learning-reports/summary": {"monthLabel": "1월"}, + } + if path in simple: + return ok(simple[path]) + if re.fullmatch(r"/api/folders/\d+/subfolders/V2|/api/problems/folder/\d+/V2", path): + return ok(page) + return error(404, 9999) + + if method == "POST" and path == "/api/folders": + parent = folders.get(body.get("parentFolderId")) + if not parent or parent["deleted"]: + return error(404, 5001) + fid = self.new_id() + folders[fid] = {"folderId": fid, "folderName": body["folderName"], "parent": parent["folderId"], "deleted": False} + return ok(fid) + if method == "PATCH" and path == "/api/folders": + f = folders.get(body.get("folderId")) + if not f or f["deleted"]: + return error(404, 5001) + f["folderName"] = body["folderName"] + return ok("폴더가 성공적으로 수정되었습니다.") + if method == "DELETE" and path == "/api/folders": + ids = body.get("deleteFolderIdList", []) + if any(i not in folders or folders[i]["deleted"] for i in ids): + return error(404, 5001) + for i in ids: + folders[i]["deleted"] = True + return ok("폴더가 성공적으로 삭제되었습니다.") + if method == "POST" and path == "/api/practiceNotes": + nid = self.new_id() + notes[nid] = {"practiceNoteId": nid, "practiceTitle": body["practiceTitle"], "deleted": False} + return ok(nid, status=201) + if method == "DELETE" and path == "/api/practiceNotes": + ids = body.get("deletePracticeIdList", []) + if any(i not in notes or notes[i]["deleted"] for i in ids): + return error(404, 6001) + for i in ids: + notes[i]["deleted"] = True + return ok("선택한 복습 노트가 삭제되었습니다.") + return error(404, 9999) + + +def ok(data, status=200): + return status, {"Content-Type": "application/json"}, json.dumps({"data": data}).encode() + + +def error(status, code): + return status, {"Content-Type": "application/json"}, json.dumps({"errorCode": code, "message": "error"}).encode() + + +class SmokeTestBase(unittest.TestCase): + + def setUp(self): + self.sleeps = [] + self.server = FakeOnO() + + def tearDown(self): + self.server.close() + + def env(self, **overrides): + env = {"SMOKE_BASE_URL": self.server.url, "SMOKE_LEVEL": "read"} + env.update({k: v for k, v in overrides.items() if v is not None}) + return env + + def run_smoke(self, level="read", secret=SERVER_SECRET, user_id=SMOKE_USER, **extra): + env = self.env(SMOKE_LEVEL=level, SMOKE_ACCESS_TOKEN_SECRET=secret, SMOKE_USER_ID=user_id, **extra) + return smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW) + + def assert_only_allowed_requests(self): + for method, path in self.server.paths(): + self.assertTrue(any(m == method and re.fullmatch(p, path) for m, p in smoke_test.ALLOWED_REQUESTS), + f"허용 목록 밖 요청: {method} {path}") + self.assertNotEqual("/api/users", path) + self.assertLessEqual(len(self.server.requests), smoke_test.MAX_REQUESTS) + + +class ReachTest(SmokeTestBase): + + def test_reach_level_sends_one_request_without_token(self): + self.assertEqual(0, self.run_smoke(level="reach")) + self.assertEqual([("GET", smoke_test.PROBE_PATH)], self.server.paths()) + self.assertNotIn("Authorization", self.server.requests[0][2]) + + def test_without_secret_only_reach_runs(self): + self.assertEqual(0, self.run_smoke(secret=None, user_id=None)) + self.assertEqual(1, len(self.server.requests)) + + def test_fails_when_endpoint_is_open_without_token(self): + self.server.override("GET", smoke_test.PROBE_PATH, ok(0)) + self.assertEqual(1, self.run_smoke()) + self.assertEqual(1, len(self.server.requests), "첫 확인이 실패하면 요청을 더 보내지 않는다") + + def test_retries_bad_gateway_then_fails(self): + self.server.override("GET", smoke_test.PROBE_PATH, (502, {"Content-Type": "text/html"}, b"502")) + self.assertEqual(1, self.run_smoke()) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + self.assertEqual([smoke_test.RETRY_DELAY_SECONDS] * (smoke_test.MAX_ATTEMPTS - 1), self.sleeps) + + def test_recovers_when_first_attempt_fails(self): + self.server.override("GET", smoke_test.PROBE_PATH, (503, {}, b""), times=1) + self.assertEqual(0, self.run_smoke(level="reach")) + self.assertEqual(2, len(self.server.requests)) + + def test_reports_origin_unreachable_behind_cloudflare(self): + # 2026-09-15 dev 도메인에서 실제로 받은 응답 모양이다 (server: cloudflare, 본문 "error code: 502"). + self.server.override("GET", smoke_test.PROBE_PATH, + (502, {"Server": "cloudflare", "Content-Type": "text/plain"}, b"error code: 502")) + smoke = smoke_test.SmokeRun(self.env(), self.sleeps.append, lambda: NOW) + self.assertFalse(smoke.check_reachable(smoke_test.Client(self.server.url, self.sleeps.append, smoke.budget))) + self.assertIn("원 서버에 닿지 못했습니다", smoke.results[0].detail) + + def test_reports_cloudflare_block_separately(self): + self.server.override("GET", smoke_test.PROBE_PATH, + (403, {"Server": "cloudflare", "cf-mitigated": "challenge"}, b"Just a moment")) + self.assertEqual(1, self.run_smoke()) + + def test_fails_on_maintenance_page(self): + self.server.override("GET", smoke_test.PROBE_PATH, (200, {"Content-Type": "text/html"}, b"maintenance")) + self.assertEqual(1, self.run_smoke()) + + def test_does_not_follow_redirect(self): + self.server.override("GET", smoke_test.PROBE_PATH, (302, {"Location": "/api/users"}, b"")) + self.assertEqual(1, self.run_smoke()) + self.assertEqual([("GET", smoke_test.PROBE_PATH)], self.server.paths()) + self.assertEqual([], self.sleeps, "3xx 는 재시도하지 않는다") + + def test_retries_rate_limited_response(self): + self.server.override("GET", smoke_test.PROBE_PATH, (429, {}, b"")) + self.assertEqual(1, self.run_smoke()) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + + def test_times_out_on_slow_server(self): + release = threading.Event() + + def slow(method, path, headers, body): + release.wait(5) + return error(401, 1007) + self.server.override("GET", smoke_test.PROBE_PATH, slow) + original = smoke_test.REQUEST_TIMEOUT_SECONDS + smoke_test.REQUEST_TIMEOUT_SECONDS = 0.3 + try: + self.assertEqual(1, self.run_smoke()) + finally: + smoke_test.REQUEST_TIMEOUT_SECONDS = original + release.set() + + def test_fails_when_server_is_unreachable(self): + url = self.server.url + self.server.close() + self.server = FakeOnO() + env = {"SMOKE_BASE_URL": url} + self.assertEqual(1, smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW)) + + +class AuthTest(SmokeTestBase): + + def test_without_user_id_checks_count_with_nonexistent_user(self): + self.assertEqual(0, self.run_smoke(user_id=None)) + self.assertEqual([("GET", smoke_test.PROBE_PATH)] * 2, self.server.paths()) + token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") + self.assertEqual("0", verify_token(token, SERVER_SECRET)["sub"]) + + def test_token_for_smoke_user_is_guest_with_short_ttl(self): + self.run_smoke() + token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") + claims = verify_token(token, SERVER_SECRET) + self.assertEqual(SMOKE_USER, claims["sub"]) + self.assertEqual("ROLE_GUEST", claims["authority"]) + self.assertEqual(smoke_test.TOKEN_TTL_SECONDS, claims["exp"] - claims["iat"]) + + def test_fails_when_signing_key_differs(self): + self.assertEqual(1, self.run_smoke(secret=OTHER_SECRET)) + self.assertEqual(2, len(self.server.requests), "토큰이 거절되면 조회를 더 보내지 않는다") + + def test_old_server_reports_wrong_key_as_expired(self): + # 운영 main(2026-07-01)은 서명이 틀려도 1005 를 준다. 실패로 잡고 서명 키 가능성도 안내해야 한다. + self.server.override("GET", "/api/folders/root", error(401, 1005)) + smoke = smoke_test.SmokeRun(self.env(SMOKE_ACCESS_TOKEN_SECRET=SERVER_SECRET, SMOKE_USER_ID=SMOKE_USER), + self.sleeps.append, lambda: NOW) + smoke.execute() + failed = [r for r in smoke.results if not r.passed and not r.skipped] + self.assertIn("서명 키", failed[0].detail) + + def test_boolean_data_is_not_a_count(self): + self.server.override("GET", smoke_test.PROBE_PATH, lambda m, p, h, b: ok(True) if h.get("Authorization") else error(401, 1007)) + self.assertEqual(1, self.run_smoke(user_id=None)) + + +class ReadTest(SmokeTestBase): + + def test_read_level_passes_and_sends_only_gets(self): + self.assertEqual(0, self.run_smoke(level="read")) + self.assertEqual({"GET"}, {m for m, _ in self.server.paths()}) + self.assert_only_allowed_requests() + self.assertEqual(17, len(self.server.requests)) + + def test_stops_when_account_has_no_marker_folder(self): + # SMOKE_USER_ID 를 실사용자 ID 로 잘못 넣은 경우. 루트 폴더 한 번만 읽고 멈춘다. + self.assertEqual(1, self.run_smoke(level="full", user_id=REAL_USER)) + self.assertEqual([("GET", smoke_test.PROBE_PATH), ("GET", "/api/folders/root")], self.server.paths()) + self.assertEqual(self.server.live_folder_names(REAL_USER), + sorted(["책장", "공책", "수학", "__smoke_run_looks_like_but_real_user"])) + + def test_stops_when_root_folder_belongs_to_another_user(self): + real_root = next(iter(self.server.users[REAL_USER]["folders"].values())) + self.server.override("GET", "/api/folders/root", ok({"folderId": real_root["folderId"], "userId": int(REAL_USER), + "subFolderList": [{"folderId": 1, "folderName": "__smoke_account__"}]})) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, len(self.server.requests)) + + def test_does_not_leak_root_body_when_shape_is_unexpected(self): + self.server.override("GET", "/api/folders/root", ok({"secretNote": "실사용자 메모"})) + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.assertEqual(1, self.run_smoke(level="full", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output)) + for path in (summary, output): + with open(path, encoding="utf-8") as f: + self.assertNotIn("실사용자 메모", f.read()) + + def test_deadline_stops_the_run(self): + original = smoke_test.DEADLINE_SECONDS + smoke_test.DEADLINE_SECONDS = 0 + try: + self.assertEqual(1, self.run_smoke(level="read")) + finally: + smoke_test.DEADLINE_SECONDS = original + self.assertEqual([], self.server.requests) + + def test_one_failing_read_does_not_stop_other_reads(self): + self.server.override("GET", "/api/tags", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="read")) + self.assertIn(("GET", "/api/learning-reports/summary"), self.server.paths()) + + def test_read_shape_mismatch_fails(self): + self.server.override("GET", "/api/folders/thumbnails/V2", ok([])) + self.assertEqual(1, self.run_smoke(level="read")) + + def test_writes_summary_and_outputs(self): + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.server.override("GET", "/api/tags", ok("not a list")) + self.run_smoke(level="read", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output) + with open(summary, encoding="utf-8") as f: + self.assertIn("## 스모크 테스트", f.read()) + with open(output, encoding="utf-8") as f: + text = f.read() + self.assertIn("failed=1", text) + self.assertIn("first_failure=조회: 태그", text) + + +class WriteTest(SmokeTestBase): + + def test_full_level_creates_and_removes_only_its_own_data(self): + before_folders = self.server.live_folder_names(SMOKE_USER) + before_notes = self.server.live_note_titles(SMOKE_USER) + real_before = self.server.live_folder_names(REAL_USER) + + self.assertEqual(0, self.run_smoke(level="full")) + + self.assertEqual(before_folders, self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(before_notes, self.server.live_note_titles(SMOKE_USER)) + self.assertEqual(real_before, self.server.live_folder_names(REAL_USER)) + self.assert_only_allowed_requests() + for method, path, headers, body in self.server.requests: + if method == "POST" and path == "/api/practiceNotes": + self.assertNotIn("practiceNotification", body, "알림을 붙이면 Quartz 작업이 남는다") + if method in ("POST", "PATCH", "DELETE"): + self.assertEqual(SMOKE_USER, verify_token(headers["Authorization"].removeprefix("Bearer "), SERVER_SECRET)["sub"]) + + def test_failed_read_blocks_writes(self): + self.server.override("GET", "/api/problems/review-due", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual([], self.server.paths("POST") + self.server.paths("PATCH") + self.server.paths("DELETE")) + + def test_server_refusing_delete_keeps_marker_and_reports(self): + self.server.override("DELETE", "/api/folders", error(400, 5002)) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertIn("__smoke_account__", self.server.live_folder_names(SMOKE_USER)) + + def test_read_level_never_writes(self): + self.run_smoke(level="read") + self.assertEqual([], self.server.paths("POST") + self.server.paths("PATCH") + self.server.paths("DELETE")) + + def test_cleans_leftovers_but_never_marker_or_other_folders(self): + user = self.server.users[SMOKE_USER] + leftover = self.server.new_id() + user["folders"][leftover] = {"folderId": leftover, "folderName": "__smoke_run_old", "parent": user["root"], "deleted": False} + note = self.server.new_id() + user["notes"][note] = {"practiceNoteId": note, "practiceTitle": "__smoke_run_old", "deleted": False} + + self.assertEqual(0, self.run_smoke(level="full")) + + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(["복습 세트"], self.server.live_note_titles(SMOKE_USER)) + deleted_ids = [i for m, p, _, b in self.server.requests if m == "DELETE" for i in + (b.get("deleteFolderIdList") or b.get("deletePracticeIdList") or [])] + marker_id = next(f["folderId"] for f in user["folders"].values() if f["folderName"] == "__smoke_account__") + self.assertNotIn(marker_id, deleted_ids) + self.assertNotIn(user["root"], deleted_ids) + + def test_failure_mid_flow_still_cleans_up(self): + self.server.override("PATCH", "/api/folders", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.server.override("POST", "/api/practiceNotes", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(1, len(self.server.paths("PATCH")), "쓰기는 5xx 여도 재시도하지 않는다") + self.assertEqual(1, self.server.paths("POST").count(("POST", "/api/practiceNotes"))) + + def test_delete_not_reflected_is_reported_and_retried_in_cleanup(self): + def fake_delete(method, path, headers, body): + return ok("폴더가 성공적으로 삭제되었습니다.") + self.server.override("DELETE", "/api/folders", fake_delete, times=1) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, self.server.paths("DELETE").count(("DELETE", "/api/folders")), "흐름의 삭제 한 번, 마무리 정리 한 번") + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + + def test_request_budget_stops_the_run(self): + original = smoke_test.MAX_REQUESTS + smoke_test.MAX_REQUESTS = 10 + try: + self.assertEqual(1, self.run_smoke(level="full")) + finally: + smoke_test.MAX_REQUESTS = original + self.assertLessEqual(len(self.server.requests), 10) + + +class ClientGuardTest(SmokeTestBase): + + def client(self, **kwargs): + return smoke_test.Client(self.server.url, self.sleeps.append, smoke_test.Budget(), **kwargs) + + def test_blocks_requests_outside_allowlist_before_sending(self): + client = self.client(token="Bearer x", allow_writes=True) + for method, path in [("GET", "/api/users"), ("PATCH", "/api/users"), ("DELETE", "/api/users"), + ("POST", "/api/auth/logout"), ("POST", "/api/problems/v2"), ("GET", "/actuator/health")]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request(method, path) + self.assertEqual([], self.server.requests) + + def test_blocks_writes_unless_full_level(self): + with self.assertRaises(smoke_test.SmokeAbort): + self.client(token="Bearer x").request("POST", "/api/folders", {"folderName": "x"}) + self.assertEqual([], self.server.requests) + + def test_blocks_signup_outside_bootstrap(self): + with self.assertRaises(smoke_test.SmokeAbort): + self.client(allow_writes=True).request("POST", smoke_test.SIGNUP_PATH) + self.assertEqual([], self.server.requests) + + def test_refuses_to_send_token_over_plain_http(self): + env = {"SMOKE_BASE_URL": "http://ono-dev.seungminki.shop", "SMOKE_ACCESS_TOKEN_SECRET": SERVER_SECRET} + self.assertEqual(2, smoke_test.run(env, sleep=self.sleeps.append)) + + def test_config_errors(self): + self.assertEqual(2, smoke_test.run({}, sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_LEVEL="everything"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_USER_ID="abc"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_ACCESS_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual([], self.server.requests) + + +class BootstrapTest(SmokeTestBase): + + def test_creates_guest_with_marker_folder_and_prints_id(self): + with tempfile.TemporaryDirectory() as d: + summary = os.path.join(d, "summary.md") + self.assertEqual(0, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url, "GITHUB_STEP_SUMMARY": summary}, + sleep=self.sleeps.append)) + with open(summary, encoding="utf-8") as f: + text = f.read() + new_ids = [u for u in self.server.users if u not in (SMOKE_USER, REAL_USER)] + self.assertEqual(1, len(new_ids)) + self.assertIn("__smoke_account__", self.server.live_folder_names(new_ids[0])) + self.assertIn(new_ids[0], text) + self.assertEqual(["POST", "GET", "POST"], [m for m, _ in self.server.paths()]) + + def test_new_account_then_passes_full_level(self): + smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url}, sleep=self.sleeps.append) + new_id = next(u for u in self.server.users if u not in (SMOKE_USER, REAL_USER)) + self.assertEqual(0, self.run_smoke(level="full", user_id=new_id)) + + def test_refuses_when_account_already_configured(self): + self.assertEqual(2, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url, "SMOKE_USER_ID": SMOKE_USER}, + sleep=self.sleeps.append)) + self.assertEqual([], self.server.requests) + + def test_signup_is_not_retried(self): + self.server.override("POST", smoke_test.SIGNUP_PATH, (502, {}, b"")) + self.assertEqual(1, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url}, sleep=self.sleeps.append)) + self.assertEqual(1, len(self.server.requests)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java b/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java new file mode 100644 index 00000000..6b652a6e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.achievement.controller; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.service.AchievementService; +import com.aisip.OnO.backend.common.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 훈장 API. + * + *

사용자 식별자는 요청 본문이나 파라미터가 아니라 인증 컨텍스트에서만 꺼낸다. 밖에서 받으면 + * 남의 훈장을 들여다보는 요청을 그대로 받아들이게 된다. {@code CosmeticController} 와 같은 방식이다. + * + *

{@code /api/achievements} 는 SecurityConfig 의 별도 매처에 걸리지 않아 + * {@code anyRequest().authenticated()} 를 탄다. 비로그인 요청은 401 이다. + * + *

GET 이지만 쓰기가 일어난다. 판정을 조회 시점에 하고 새로 채운 훈장을 그 자리에 적기 때문이다. + * 조건을 적립 경로마다 심으면 이 기능이 붙기 전에 이미 오답노트를 백 개 적은 사람이 아무것도 못 받는데, + * 조회할 때 세면 소급이 저절로 된다. 같은 요청을 몇 번을 보내도 행은 하나라 멱등하다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/achievements") +public class AchievementController { + + private final AchievementService achievementService; + + @GetMapping("") + public CommonResponse getAchievements() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(achievementService.getAchievements(userId)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java new file mode 100644 index 00000000..afef8f65 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.achievement.dto; + +import java.util.List; + +/** + * {@code GET /api/achievements} 응답. + * + * @param achievements 열두 개 전부. 받은 것과 못 받은 것이 섞여 있다. + * 순서는 훈장표의 순서 그대로다. 앱이 이 순서를 그대로 그리고 다시 정렬하지 않는다. + * @param newlyEarned 이번 호출에서 처음 채워진 훈장의 key 들. 앱이 "새 훈장을 받았어요" 연출에 쓴다. + * 없으면 빈 배열이다. 같은 사람이 연달아 부르면 두 번째부터는 비어 있다. + */ +public record AchievementListResponseDto( + List achievements, + List newlyEarned +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java new file mode 100644 index 00000000..06bf81bb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.achievement.dto; + +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.service.AchievementStats; + +import java.time.LocalDateTime; + +/** + * 훈장 한 건. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. + * + * @param imageUrl 앱 번들 안의 에셋 경로. 치장이 {@code assets/Cosmetic/...} 을 내려주는 것과 같다. + * @param earned 받았는지. 받은 뒤에는 조건을 다시 계산해 안 맞아도 참이다. + * @param earnedAt 처음 받은 시각(KST). 못 받았으면 null. + * @param current 지금까지 온 만큼. 목표치를 넘으면 목표치로 잘린다. 진행도가 없는 훈장이면 null. + * @param target 받는 데 필요한 만큼. 진행도가 없는 훈장(첫 걸음·불사조)이면 null. + */ +public record AchievementResponseDto( + String key, + String nameKo, + String descriptionKo, + String imageUrl, + boolean earned, + LocalDateTime earnedAt, + Long current, + Long target +) { + + public static AchievementResponseDto of(Achievement achievement, AchievementStats stats, + LocalDateTime earnedAt) { + return new AchievementResponseDto( + achievement.getKey(), + achievement.getNameKo(), + achievement.getDescriptionKo(), + achievement.getImageUrl(), + earnedAt != null, + earnedAt, + achievement.progressOf(stats).orElse(null), + achievement.targetValue().orElse(null) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java new file mode 100644 index 00000000..ae065d64 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java @@ -0,0 +1,140 @@ +package com.aisip.OnO.backend.achievement.entity; + +import com.aisip.OnO.backend.achievement.service.AchievementStats; +import lombok.Getter; + +import java.util.Optional; +import java.util.function.ToLongFunction; + +/** + * 훈장 열두 개. 훈장표(`OnO_FRONT/docs/훈장/훈장표.md`)가 계약이고 이 enum 이 그 사본이다. + * + *

선언 순서가 곧 응답 순서다. 앱은 서버가 준 순서를 그대로 그리고 다시 정렬하지 않는다. + * 순서를 바꾸면 화면의 훈장 배치가 바뀐다. 훈장표의 순서와 여기의 순서는 항상 같아야 한다. + * + *

카탈로그 테이블을 두지 않는다. 치장 아이템과 달리 훈장은 관리자가 늘리는 것이 아니라 + * 에셋과 함께 배포되는 것이라, 테이블을 두면 시드 마이그레이션과 앱 에셋이 따로 놀 여지만 생긴다. + * 코드에 두면 열두 개가 어긋날 수 없다. + * + *

조건은 전부 {@code 진행도 >= threshold} 한 가지 꼴로 맞췄다. "틀린 뒤에 맞혔는가" 처럼 + * 참/거짓인 것도 0 또는 1 을 내는 진행도로 바꿔 같은 틀에 넣는다. 조건마다 다른 판정을 쓰면 + * 훈장이 늘 때마다 판정 코드가 따라 늘고, 어떤 훈장이 어떤 규칙을 쓰는지 한눈에 안 보인다. + * + *

{@code showsProgress} 가 거짓인 둘({@link #FIRST_STEP}, {@link #PHOENIX})은 진행도를 안 내려준다. + * 0 아니면 1 이라 "0/1" 을 보여 줘 봐야 잠김 여부를 두 번 말하는 것뿐이다. 훈장표가 정한 것이다. + */ +@Getter +public enum Achievement { + + FIRST_STEP("first_step", "첫 걸음", "오답노트를 처음 적었어요", + 1, false, AchievementStats::problemCount), + + ARCHIVIST("archivist", "기록광", "오답노트를 백 개나 모았어요", + 100, true, AchievementStats::problemCount), + + PERSISTENCE("persistence", "집념", "한 문제를 다섯 번 다시 봤어요", + 5, true, AchievementStats::maxSolveCountOnOneProblem), + + PHOENIX("phoenix", "불사조", "틀렸던 문제를 기어이 맞혔어요", + 1, false, AchievementStats::comebackCount), + + DAWN_CLASS("dawn_class", "새벽반", "해 뜨기 전에 열 번 공부했어요", + 10, true, AchievementStats::dawnSolveCount), + + NIGHT_OWL("night_owl", "올빼미", "밤이 깊어도 열 번 공부했어요", + 10, true, AchievementStats::nightSolveCount), + + PERFECT_MONTH("perfect_month", "개근", "서른 날을 하루도 안 빼먹었어요", + 30, true, AchievementStats::longestLoginStreak), + + FLAWLESS("flawless", "무결점", "열 번을 내리 맞혔어요", + 10, true, AchievementStats::longestCorrectStreak), + + ORGANIZER("organizer", "정리의 신", "폴더를 열 개나 만들어 정리했어요", + 10, true, AchievementStats::folderCount), + + REVIEWER("reviewer", "회고왕", "복습하고 쉰 번을 되돌아봤어요", + 50, true, AchievementStats::reflectionCount), + + COMPANION("companion", "동행", "같이 공부할 사람을 찾았어요", + 1, true, AchievementStats::studyRoomCount), + + CHEERLEADER("cheerleader", "응원단장", "백 번을 응원했어요", + 100, true, AchievementStats::reactionCount); + + /** + * 앱 번들 안의 에셋 경로. 치장이 {@code assets/Cosmetic/...} 을 내려주는 것과 같은 방식이다. + * + *

키에서 만들어 낸다. 열두 줄에 같은 접두사를 되풀이해 적으면 한 줄만 오타가 나도 + * 그 훈장만 이미지가 안 뜨는데, 그건 앱을 켜 보기 전에는 드러나지 않는다. + */ + private static final String IMAGE_URL_PREFIX = "assets/Medal/"; + private static final String IMAGE_URL_SUFFIX = ".png"; + + /** 앱과 DB 가 공유하는 식별자. enum 이름이 아니라 이 값이 계약이다. */ + private final String key; + + private final String nameKo; + + private final String descriptionKo; + + /** 이 값에 닿으면 받는다. 경계는 포함이다. */ + private final int threshold; + + /** 진행도를 내려줄지. 거짓이면 응답의 {@code current}/{@code target} 이 둘 다 null 이다. */ + private final boolean showsProgress; + + private final ToLongFunction progress; + + Achievement(String key, String nameKo, String descriptionKo, + int threshold, boolean showsProgress, ToLongFunction progress) { + this.key = key; + this.nameKo = nameKo; + this.descriptionKo = descriptionKo; + this.threshold = threshold; + this.showsProgress = showsProgress; + this.progress = progress; + } + + public String getImageUrl() { + return IMAGE_URL_PREFIX + key + IMAGE_URL_SUFFIX; + } + + /** 지금까지 쌓인 데이터로 이 훈장의 조건을 채웠는지. */ + public boolean isSatisfiedBy(AchievementStats stats) { + return progress.applyAsLong(stats) >= threshold; + } + + /** + * 화면에 보여 줄 진행도. 진행도가 없는 훈장이면 비어 있다. + * + *

목표치를 넘어도 목표치로 잘라서 준다. 오답노트를 187 개 적은 사람에게 "187/100" 을 + * 보여 주면 진행 막대가 계산되지 않고, 이미 받은 훈장에서 숫자가 계속 자라는 것도 이상하다. + */ + public Optional progressOf(AchievementStats stats) { + if (!showsProgress) { + return Optional.empty(); + } + return Optional.of(Math.min(progress.applyAsLong(stats), threshold)); + } + + /** 진행도를 내려주는 훈장의 목표치. 진행도가 없으면 비어 있다. */ + public Optional targetValue() { + return showsProgress ? Optional.of((long) threshold) : Optional.empty(); + } + + /** + * 저장된 키를 훈장으로 되돌린다. 모르는 키면 비어 있다. + * + *

훈장을 뺀 배포를 하면 예전 행의 키가 여기 안 잡힌다. 그때 예외를 던지면 훈장 하나 때문에 + * 훈장 화면 전체가 안 열린다. 모르는 행은 조용히 지나친다. + */ + public static Optional fromKey(String key) { + for (Achievement achievement : values()) { + if (achievement.key.equals(key)) { + return Optional.of(achievement); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java new file mode 100644 index 00000000..114fdb01 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.achievement.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자가 받은 훈장. (userId, achievementKey) 하나당 한 행이다. + * + *

(user_id, achievement_key) 복합 기본키가 멱등성의 근거다. 판정이 조회 때마다 도는 구조라 + * 같은 사람이 훈장 화면을 두 번 열면 같은 INSERT 가 두 번 나간다. 중복을 막는 것을 애플리케이션 + * 검사에 맡기면 두 요청이 동시에 "없다"를 읽고 둘 다 INSERT 하는 창이 열린다. DB 가 막으면 그 창이 없다. + * + *

이 엔티티는 읽기 전용으로만 쓴다. 쓰기는 리포지토리의 네이티브 upsert 를 탄다. + * {@link com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout} 과 같은 방식이다. + * + *

키는 {@link Achievement} 의 enum 이름이 아니라 {@code archivist} 같은 계약 키를 담는다. + * API 와 앱 에셋 경로가 쓰는 값과 같아야 행을 보고 무엇인지 알 수 있다. + * + *

user 에 외래키를 걸지 않는다. {@code user_cosmetic_loadout} 과 같은 이유로, INSERT 마다 + * 부모 사용자 행에 공유 잠금이 붙으면 사용자 행을 배타 잠금으로 잡는 미션 보상 지급 경로와 + * 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@IdClass(UserAchievementId.class) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_achievement") +public class UserAchievement { + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * V42 의 {@code achievement_key VARCHAR(32)} 와 맞춘다. {@code columnDefinition} 을 함께 적는 이유는 + * {@code UserCosmeticLoadout.slot} 과 같다. 길이가 테스트 스키마와 운영 스키마에서 갈리면 + * 길이 초과로 나는 오류를 테스트가 재현하지 못한다. + */ + @Id + @Column(name = "achievement_key", nullable = false, length = 32, columnDefinition = "varchar(32)") + private String achievementKey; + + /** 이 훈장을 처음 채운 시각. KST 다. 근거는 AchievementService 주석에 있다. */ + @Column(name = "earned_at", nullable = false) + private LocalDateTime earnedAt; +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java new file mode 100644 index 00000000..fcc4a9fe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.achievement.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * {@link UserAchievement} 의 복합 기본키 (user_id, achievement_key). + * + *

JPA 는 복합키를 별도 클래스로 요구하고, 그 클래스는 직렬화 가능하며 equals/hashCode 가 + * 값 기준이어야 한다. 둘 중 하나라도 빠지면 같은 키를 두 번 조회할 때 영속성 컨텍스트가 + * 다른 행으로 착각한다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class UserAchievementId implements Serializable { + + private Long userId; + + private String achievementKey; +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java b/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java new file mode 100644 index 00000000..75945cb8 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.achievement.repository; + +import com.aisip.OnO.backend.achievement.entity.UserAchievement; +import com.aisip.OnO.backend.achievement.entity.UserAchievementId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface UserAchievementRepository extends JpaRepository { + + /** 소유권: 훈장은 언제나 요청한 사용자 것만 읽는다. */ + List findAllByUserId(Long userId); + + /** + * 아직 없을 때만 넣는다. 이미 받은 훈장은 손대지 않는다. + * + *

여기가 "한 번 받은 것은 취소하지 않는다" 와 "몇 번을 불러도 행이 하나다" 를 동시에 지킨다. + * {@code ON DUPLICATE KEY UPDATE user_id = user_id} 는 "이미 있으면 그대로 둔다"는 뜻이라, + * 두 번째 호출이 {@code earned_at} 을 오늘로 덮어쓰지 않는다. 덮어쓰면 사용자가 훈장 화면을 + * 열 때마다 받은 날짜가 오늘로 바뀐다. + * + *

{@code INSERT IGNORE} 를 쓰지 않는다. 그쪽은 키 충돌만이 아니라 길이 초과·타입 불일치 같은 + * 진짜 오류까지 경고로 삼켜 버린다. 훈장 키가 32 자를 넘게 되면 조용히 잘린 행이 남는 대신 + * 여기서 터져야 한다. + * + *

{@code earned_at} 을 {@code NOW(6)} 이 아니라 인자로 받는다. DB 서버의 시간대가 앱과 다르면 + * 받은 시각이 아홉 시간 어긋난 채로 앱에 뜬다. 기준 시각은 애플리케이션이 정한다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_achievement (user_id, achievement_key, earned_at) + VALUES (:userId, :achievementKey, :earnedAt) + ON DUPLICATE KEY UPDATE user_id = user_id + """, nativeQuery = true) + int insertIfAbsent( + @Param("userId") Long userId, + @Param("achievementKey") String achievementKey, + @Param("earnedAt") LocalDateTime earnedAt + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java new file mode 100644 index 00000000..a7d9a8e5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java @@ -0,0 +1,111 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.dto.AchievementResponseDto; +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.entity.UserAchievement; +import com.aisip.OnO.backend.achievement.repository.UserAchievementRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * 훈장 조회와 판정. + * + *

판정은 조회할 때 한다. 미션처럼 행동이 일어날 때마다 따지는 방법도 있지만, 그러면 열두 + * 조건이 적립 경로 네 군데에 흩어지고 무엇보다 이 기능을 붙이기 전에 이미 오답노트를 백 개 적은 + * 사람이 아무것도 못 받는다. 지금까지 쌓인 데이터를 그 자리에서 세면 소급이 저절로 된다. + * 화면을 열 때 한 번 도는 비용이고, 몇 번 나가는지는 {@link AchievementStatsCollector} 주석에 있다. + * + *

한 번 받은 것은 취소하지 않는다. 조건을 다시 계산했을 때 안 맞아도 이미 받은 것은 그대로 + * 둔다. 오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다. {@code user_achievement} 에 + * 행이 있으면 그걸로 끝이라, 아래 어디에도 지우는 경로가 없다. + * + *

엔티티를 고쳐 더티 체킹에 맡기지 않고 리포지토리의 네이티브 upsert 만 쓴다. 멱등성의 근거가 + * {@code (user_id, achievement_key)} 기본키인데, 읽고 나서 쓰는 방식으로 바꾸면 그 보장이 사라진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AchievementService { + + /** + * 받은 시각의 기준 시간대. + * + *

운영과 테스트 모두 {@code -Duser.timezone=Asia/Seoul} 이 걸려 있어 인자 없는 + * {@code LocalDateTime.now()} 로도 같은 값이 나온다. 그래도 명시한다. 기본 시간대에 기대면 + * 배포 환경이 바뀌는 날 받은 시각이 아홉 시간 어긋나고, 그건 아무도 안 보는 사이에 일어난다. + * {@code MissionPeriodKey} 가 같은 이유로 KST 를 못박고 있다. + */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AchievementStatsCollector statsCollector; + private final UserAchievementRepository userAchievementRepository; + + /** + * 훈장 열두 개 전부와, 이번 호출에서 새로 채운 것. + * + *

읽기 전용이 아니다. 새로 채운 훈장을 이 자리에서 적는다. 적어 두지 않으면 조건이 되는 데이터를 + * 지운 순간 받은 적 없는 것이 되고, "받은 날짜"를 말할 수도 없다. + * + *

같은 사람이 연달아 두 번 불러도 두 번째의 {@code newlyEarned} 는 비어 있다. 두 번째 호출은 + * 첫 호출이 적어 둔 행을 읽고 시작하기 때문이다. 동시에 두 번 들어오면 두 응답이 같은 훈장을 + * 한 번씩 새것이라 말할 수 있지만, 그때도 행은 하나다. 최악이 기기 두 대에서 축하 연출이 + * 한 번씩 뜨는 것이라 DB 를 잠가 가며 막을 값어치가 없다고 봤다. + */ + @Transactional + public AchievementListResponseDto getAchievements(Long userId) { + AchievementStats stats = statsCollector.collect(userId); + Map earnedAtByAchievement = earnedAtByAchievement(userId); + + // 초 아래를 버린다. 훈장을 받은 시각에 마이크로초가 필요할 일이 없고, 계약 문서의 예시도 초 단위다. + LocalDateTime now = LocalDateTime.now(KST).truncatedTo(ChronoUnit.SECONDS); + + List newlyEarned = new ArrayList<>(); + // values() 의 순서가 곧 훈장표의 순서다. 여기서 다시 정렬하지 않는다. + for (Achievement achievement : Achievement.values()) { + if (earnedAtByAchievement.containsKey(achievement) || !achievement.isSatisfiedBy(stats)) { + continue; + } + userAchievementRepository.insertIfAbsent(userId, achievement.getKey(), now); + earnedAtByAchievement.put(achievement, now); + newlyEarned.add(achievement.getKey()); + } + + if (!newlyEarned.isEmpty()) { + log.info("userId: {} earned achievements: {}", userId, newlyEarned); + } + + List achievements = Arrays.stream(Achievement.values()) + .map(achievement -> AchievementResponseDto.of( + achievement, stats, earnedAtByAchievement.get(achievement))) + .toList(); + + return new AchievementListResponseDto(achievements, newlyEarned); + } + + /** + * 이미 받은 훈장과 그 시각. + * + *

모르는 키가 든 행은 그냥 넘긴다. 훈장을 빼는 배포를 하면 예전 행의 키가 enum 에 없는데, + * 거기서 예외를 던지면 훈장 하나 때문에 훈장 화면 전체가 안 열린다. + */ + private Map earnedAtByAchievement(Long userId) { + Map earned = new EnumMap<>(Achievement.class); + for (UserAchievement row : userAchievementRepository.findAllByUserId(userId)) { + Achievement.fromKey(row.getAchievementKey()) + .ifPresent(achievement -> earned.put(achievement, row.getEarnedAt())); + } + return earned; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java new file mode 100644 index 00000000..0d9146a4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java @@ -0,0 +1,33 @@ +package com.aisip.OnO.backend.achievement.service; + +/** + * 한 사용자의 지금까지가 숫자로 접힌 것. 훈장 열두 개의 판정이 전부 이 값들만 본다. + * + *

판정과 세기를 갈라 두는 이유는 시험 가능성 때문이다. 조건이 맞는지 따지는 쪽 + * ({@link com.aisip.OnO.backend.achievement.entity.Achievement})에 DB 접근이 섞여 있으면 + * "오답노트 99 개와 100 개의 경계" 같은 것을 확인하려고 매번 오답노트 백 개를 만들어야 한다. + * + * @param maxSolveCountOnOneProblem 한 문제에 몰린 복습 횟수의 최댓값. 다섯 문제를 한 번씩 본 것과 + * 한 문제를 다섯 번 본 것은 다르다. + * @param comebackCount 오답 뒤에 정답이 나온 적이 있으면 1, 없으면 0. 참/거짓을 + * 다른 조건과 같은 "진행도 >= 목표" 틀에 넣으려고 숫자로 둔다. + * @param dawnSolveCount KST 05:00 이상 08:00 미만에 한 복습 횟수. + * @param nightSolveCount KST 00:00 이상 03:00 미만에 한 복습 횟수. + * @param longestCorrectStreak 복습 시각 순서로 정답이 연달아 나온 최대 길이. + * @param longestLoginStreak 로그인이 하루도 안 끊긴 최대 일수. 오늘까지 이어질 필요는 없다. + * @param reactionCount 리액션 세 종류(피드·공유 문제·공유 문제 댓글)를 합친 수. + */ +public record AchievementStats( + long problemCount, + long folderCount, + long maxSolveCountOnOneProblem, + long comebackCount, + long dawnSolveCount, + long nightSolveCount, + long reflectionCount, + long longestCorrectStreak, + long longestLoginStreak, + long studyRoomCount, + long reactionCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java new file mode 100644 index 00000000..77f73925 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java @@ -0,0 +1,122 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.folder.repository.FolderRepository; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemReactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 훈장 판정에 쓸 숫자를 모은다. + * + *

이 클래스가 조회 한 번에 나가는 쿼리 수를 정한다. 지금은 아홉 번이다. + * 오답노트 1, 폴더 1, 복습 기록 1, 로그인 날짜 1, 스터디룸 1, 리액션 3, 그리고 + * 서비스가 따로 읽는 받은 훈장 목록 1 이다. + * + *

열두 조건을 각각 세면 열두 번이 넘는다. 그중 여섯(집념·불사조·새벽반·올빼미·무결점·회고왕)이 + * 같은 {@code problem_solve} 표를 보기 때문에, 그 표를 한 번만 읽어 자바에서 여섯 값을 만든다 + * ({@link SolveScan}). 나머지는 인덱스를 타는 단순 count 라 더 접을 것이 없다. + * + *

리액션 셋을 한 문장으로 합치면 아홉이 일곱이 되지만 그렇게 하지 않았다. 세 도메인에 걸친 + * 네이티브 쿼리를 어느 리포지토리에도 자연스럽게 둘 수 없는 데 비해, 줄어드는 것은 문장 두 개뿐이다. + * + *

리액션 세 테이블의 {@code COUNT(*) WHERE user_id = ?} 는 전부 인덱스를 탄다. 유니크 키가 + * {@code (feed_id, user_id, emoji)} 처럼 {@code user_id} 를 선두에 두지 않아 그것만 보면 풀스캔처럼 + * 보이지만, 세 테이블 모두 {@code user_id} 에 사용자 테이블을 향한 외래키가 걸려 있어 InnoDB 가 + * {@code (user_id)} 단독 인덱스를 함께 만든다(V6, V11). 댓글 리액션은 {@code idx_shared_problem_comment_reaction_user} + * 로 명시까지 돼 있다. {@code AchievementMigrationTest} 가 이 인덱스들이 사라지지 않는지 잠근다. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AchievementStatsCollector { + + private final ProblemRepository problemRepository; + private final FolderRepository folderRepository; + private final ProblemSolveRepository problemSolveRepository; + private final MissionLogRepository missionLogRepository; + private final StudyRoomMemberRepository studyRoomMemberRepository; + private final StudyRoomFeedReactionRepository feedReactionRepository; + private final StudyRoomSharedProblemReactionRepository sharedProblemReactionRepository; + private final StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + + public AchievementStats collect(Long userId) { + SolveScan solves = SolveScan.of(problemSolveRepository.findAllMarksByUserId(userId)); + + return new AchievementStats( + nullSafe(problemRepository.countByUserId(userId)), + folderRepository.countByUserIdAndParentFolderIsNotNull(userId), + solves.maxSolveCountOnOneProblem(), + solves.comebackCount(), + solves.dawnSolveCount(), + solves.nightSolveCount(), + solves.reflectionCount(), + solves.longestCorrectStreak(), + longestLoginStreak(userId), + studyRoomMemberRepository.countByUserId(userId), + reactionCount(userId) + ); + } + + private long longestLoginStreak(Long userId) { + List loginDates = missionLogRepository + .findDistinctLogDates(userId, MissionType.USER_LOGIN).stream() + .map(AchievementStatsCollector::toLocalDate) + .toList(); + + return ConsecutiveDays.longestRun(loginDates); + } + + /** + * 응원한 횟수. 훈장표대로 리액션 세 자리를 합친다. + * + *

누른 자리가 어디든 응원한 것은 응원한 것이다. 피드 리액션만 세면 공유 문제에만 응원을 누른 + * 사용자가 아무것도 안 한 것으로 잡힌다. + */ + private long reactionCount(Long userId) { + return feedReactionRepository.countByUserId(userId) + + sharedProblemReactionRepository.countByUserId(userId) + + commentReactionRepository.countByUserId(userId); + } + + /** + * {@code FUNCTION('DATE', ...)} 이 돌려준 값을 날짜로 맞춘다. + * + *

Hibernate 가 이 함수의 자바 타입을 정하지 않고 JDBC 드라이버가 주는 대로 넘기기 때문에 + * 드라이버 버전에 따라 {@code java.sql.Date} 일 수도 {@code LocalDate} 일 수도 있다. + * 한쪽만 받도록 적어 두면 드라이버를 올리는 날 훈장 화면이 ClassCastException 으로 닫힌다. + * {@code MissionLogService} 도 같은 이유로 같은 처리를 한다. + */ + private static LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } + + /** {@code ProblemRepository.countByUserId} 만 {@code Long} 을 돌려준다. 행이 없으면 0 이다. */ + private static long nullSafe(Long count) { + return count == null ? 0 : count; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java b/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java new file mode 100644 index 00000000..45a2eedd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.achievement.service; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +/** + * 날짜 묶음에서 하루도 안 끊긴 가장 긴 구간의 길이. + * + *

훈장 '개근'이 쓴다. 오늘까지 이어질 필요는 없다. 지난달에 서른 날을 채웠으면 그때 받은 + * 것이고, 이번 달에 하루 빠졌다고 되돌려 주지 않는다. 그래서 "지금 며칠째인가" 가 아니라 + * "가장 길었던 때가 며칠인가" 를 본다. + */ +public final class ConsecutiveDays { + + private ConsecutiveDays() { + } + + /** @param dates 중복이 있어도 된다. 안에서 정렬하고 같은 날은 하루로 접는다. */ + public static long longestRun(Collection dates) { + if (dates.isEmpty()) { + return 0; + } + + List sorted = dates.stream().distinct().sorted().toList(); + + long longest = 1; + long current = 1; + for (int i = 1; i < sorted.size(); i++) { + if (sorted.get(i - 1).plusDays(1).equals(sorted.get(i))) { + current++; + } else { + current = 1; + } + longest = Math.max(longest, current); + } + return longest; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java b/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java new file mode 100644 index 00000000..a4600f60 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java @@ -0,0 +1,116 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveMark; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 복습 기록을 시각 순서로 한 번 훑어 훈장 여섯 개가 쓸 값을 함께 만든다. + * + *

집념·불사조·새벽반·올빼미·무결점·회고왕이 전부 같은 표를 본다. 조건마다 쿼리를 따로 두면 + * 훈장 화면 한 번에 같은 표를 여섯 번 읽게 되고, 그중 둘(무결점·불사조)은 애초에 집계 함수로 안 된다. + * 순서대로 한 번 훑는 것으로 여섯 개가 전부 나온다. + */ +public record SolveScan( + long maxSolveCountOnOneProblem, + long comebackCount, + long dawnSolveCount, + long nightSolveCount, + long reflectionCount, + long longestCorrectStreak +) { + + /** 새벽반이 보는 구간. 05:00 이상 08:00 미만. */ + private static final int DAWN_START_HOUR = 5; + private static final int DAWN_END_HOUR = 8; + + /** 올빼미가 보는 구간. 00:00 이상 03:00 미만. 새벽반과 겹치지 않는다. */ + private static final int NIGHT_END_HOUR = 3; + + public static final SolveScan EMPTY = new SolveScan(0, 0, 0, 0, 0, 0); + + /** + * @param marks 복습 시각 오름차순으로 정렬된 기록. 정렬이 깨지면 무결점과 불사조가 틀린다. + */ + public static SolveScan of(List marks) { + if (marks.isEmpty()) { + return EMPTY; + } + + Map solveCountByProblem = new HashMap<>(); + // 이 문제에서 틀린 적이 있는가. 불사조는 "틀린 뒤에 맞혔는가" 라 앞에 오답이 있었는지를 기억해야 한다. + Set everWrongProblems = new HashSet<>(); + boolean comeback = false; + + long dawnCount = 0; + long nightCount = 0; + long reflectionCount = 0; + long correctStreak = 0; + long longestCorrectStreak = 0; + + for (ProblemSolveMark mark : marks) { + solveCountByProblem.merge(mark.problemId(), 1L, Long::sum); + + if (isDawn(mark.practicedAt())) { + dawnCount++; + } + if (isNight(mark.practicedAt())) { + nightCount++; + } + if (mark.hasReflection()) { + reflectionCount++; + } + + AnswerStatus status = mark.answerStatus(); + if (status == AnswerStatus.WRONG) { + everWrongProblems.add(mark.problemId()); + } + // PARTIAL 은 정답으로 치지 않는다. 부분 정답은 아직 못 맞힌 것이다. + if (status == AnswerStatus.CORRECT && everWrongProblems.contains(mark.problemId())) { + comeback = true; + } + + // UNKNOWN 은 레거시 마이그레이션이 남긴 값이라 세지도 않고 끊지도 않는다. + // 그때 맞혔는지 아닌지를 모르는 것이라, 틀렸다고 보고 끊으면 없는 실패를 만드는 셈이다. + if (status == AnswerStatus.CORRECT) { + correctStreak++; + longestCorrectStreak = Math.max(longestCorrectStreak, correctStreak); + } else if (status == AnswerStatus.WRONG || status == AnswerStatus.PARTIAL) { + correctStreak = 0; + } + } + + long maxSolveCount = solveCountByProblem.values().stream().mapToLong(Long::longValue).max().orElse(0); + + return new SolveScan( + maxSolveCount, + comeback ? 1 : 0, + dawnCount, + nightCount, + reflectionCount, + longestCorrectStreak + ); + } + + /** + * 복습 시각이 새벽 구간인지. + * + *

{@code practicedAt} 은 앱이 보낸 벽시계 시각을 그대로 담고 있고, 운영과 테스트 모두 + * JVM 시간대가 {@code Asia/Seoul} 로 못박혀 있다. 즉 이 값의 시(hour)가 곧 KST 시각이다. + * 여기서 {@code atZone(UTC)} 같은 것을 한 번 더 태우면 아홉 시간이 밀려 엉뚱한 사람이 받는다. + */ + private static boolean isDawn(LocalDateTime practicedAt) { + int hour = practicedAt.getHour(); + return hour >= DAWN_START_HOUR && hour < DAWN_END_HOUR; + } + + private static boolean isNight(LocalDateTime practicedAt) { + return practicedAt.getHour() < NIGHT_END_HOUR; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java index f021a716..b19cde46 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackController.java @@ -24,17 +24,23 @@ public String feedbackList( @RequestParam(defaultValue = "20") int size, Model model ) { - Page pageResult = feedbackService.findAll(page, size); + // 쿼리 파라미터는 사용자 입력이다. page 가 음수이거나 size 가 0 이하면 + // PageRequest.of 가 IllegalArgumentException 을 던져 관리자 화면이 500 이 됐다. + // 다른 관리자 목록 화면과 같은 방식으로 유효 범위에 맞춰 보정한다. + int selectedPage = Math.max(page, 0); + int selectedSize = Math.max(size, 1); + + Page pageResult = feedbackService.findAll(selectedPage, selectedSize); model.addAttribute("feedbacks", pageResult.getContent()); model.addAttribute("totalCount", feedbackService.count()); model.addAttribute("averageNps", feedbackService.averageNps()); - model.addAttribute("currentPage", page); + model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", pageResult.getTotalPages()); - model.addAttribute("size", size); + model.addAttribute("size", selectedSize); int blockSize = 10; - int blockStart = (page / blockSize) * blockSize; + int blockStart = (selectedPage / blockSize) * blockSize; int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1); model.addAttribute("pageBlockStart", blockStart); model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart)); diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminNoticeController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminNoticeController.java new file mode 100644 index 00000000..ad535c84 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminNoticeController.java @@ -0,0 +1,77 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.notice.dto.NoticeCreateRequestDto; +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.notice.entity.NoticeType; +import com.aisip.OnO.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Slf4j +@RequiredArgsConstructor +@Controller +@RequestMapping("/admin/notice") +public class AdminNoticeController { + + private final NoticeService noticeService; + + @GetMapping + public String noticePage(Model model) { + NoticeResponseDto activeNotice = noticeService.findActiveNoticeForAdmin(); + + model.addAttribute("activeNotice", activeNotice); + model.addAttribute("noticeTypes", NoticeType.values()); + return "admin-notice"; + } + + /** + * 공지 등록. 이미 걸려 있는 공지가 있으면 서비스가 그것을 내리고 새 공지로 바꾼다. + * + *

검증 실패를 {@link ApplicationException} 그대로 흘리면 전역 핸들러가 JSON 을 + * 내려보내서 관리자 화면이 깨진다. 여기서 잡아 메시지만 화면으로 되돌린다. + */ + @PostMapping + public String registerNotice( + @RequestParam(name = "title") String title, + @RequestParam(name = "content") String content, + @RequestParam(name = "type") NoticeType type, + @RequestParam(name = "durationHours", required = false) Integer durationHours, + RedirectAttributes redirectAttributes + ) { + try { + NoticeResponseDto notice = noticeService.registerNotice( + new NoticeCreateRequestDto(title, content, type, durationHours) + ); + redirectAttributes.addFlashAttribute("resultMessage", + "공지를 등록했습니다. " + notice.expiresAt() + " 까지 노출됩니다."); + } catch (ApplicationException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + } + + return "redirect:/admin/notice"; + } + + @PostMapping("/{noticeId}/delete") + public String removeNotice( + @PathVariable(name = "noticeId") Long noticeId, + RedirectAttributes redirectAttributes + ) { + try { + noticeService.removeNotice(noticeId); + redirectAttributes.addFlashAttribute("resultMessage", "공지를 내렸습니다."); + } catch (ApplicationException e) { + redirectAttributes.addFlashAttribute("errorMessage", e.getMessage()); + } + + return "redirect:/admin/notice"; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java index 5b7795f5..949a95ab 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java +++ b/src/main/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomController.java @@ -2,9 +2,11 @@ import com.aisip.OnO.backend.admin.dto.AdminStudyRoomDetailDto; import com.aisip.OnO.backend.admin.dto.AdminStudyRoomSummaryDto; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.studyroom.entity.StudyRoom; import com.aisip.OnO.backend.studyroom.entity.StudyRoomChallenge; import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.exception.StudyRoomErrorCase; import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; @@ -40,8 +42,14 @@ public String list( @RequestParam(defaultValue = "20") int size, Model model ) { + // 쿼리 파라미터는 사용자 입력이다. page 가 음수이거나 size 가 0 이하면 + // PageRequest.of 가 IllegalArgumentException 을 던져 관리자 화면이 500 이 됐다. + // 다른 관리자 목록 화면과 같은 방식으로 유효 범위에 맞춰 보정한다. + int selectedPage = Math.max(page, 0); + int selectedSize = Math.max(size, 1); + Page pageResult = studyRoomRepository.findAll( - PageRequest.of(page, size, Sort.by("createdAt").descending())); + PageRequest.of(selectedPage, selectedSize, Sort.by("createdAt").descending())); List roomIds = pageResult.stream().map(StudyRoom::getId).toList(); @@ -60,12 +68,12 @@ public String list( model.addAttribute("rooms", rooms); model.addAttribute("totalCount", pageResult.getTotalElements()); - model.addAttribute("currentPage", page); + model.addAttribute("currentPage", selectedPage); model.addAttribute("totalPages", pageResult.getTotalPages()); - model.addAttribute("size", size); + model.addAttribute("size", selectedSize); int blockSize = 10; - int blockStart = (page / blockSize) * blockSize; + int blockStart = (selectedPage / blockSize) * blockSize; int blockEnd = Math.min(blockStart + blockSize - 1, pageResult.getTotalPages() - 1); model.addAttribute("pageBlockStart", blockStart); model.addAttribute("pageBlockEnd", Math.max(blockEnd, blockStart)); @@ -77,8 +85,10 @@ public String list( @GetMapping("/{id}") public String detail(@PathVariable Long id, Model model) { + // IllegalArgumentException 은 GlobalExceptionHandler 의 마지막 Exception 핸들러로 떨어져 + // 500 + Discord 에러 알림이 됐다. 없는 리소스 조회는 404 다. StudyRoom room = studyRoomRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("스터디룸을 찾을 수 없습니다: " + id)); + .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); List members = studyRoomMemberRepository.findAllWithUserByRoomId(id); List challenges = studyRoomChallengeRepository.findAllByRoomIdOrderByEndAtAsc(id); diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java index 03d2b5ac..2c31ca2d 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java @@ -14,6 +14,13 @@ public record AdminPracticeLogResponseDto( Long point, LocalDateTime createdAt ) { + /** + * {@code point} 는 저장된 값이 아니라 미션 타입의 정가다. + * + *

{@code mission_log.point} 는 "이 기록으로 자동 적립이 돌았는가"를 담게 되어, 미션을 받을 수 있는 + * 앱에서 온 요청의 행은 0 이다(#318). 이 화면이 보여 주는 값의 뜻은 "복습 세트 완료 하나의 값어치"라 + * 적립 여부와 무관하므로, 화면이 지금까지 보여 주던 값을 그대로 유지한다. + */ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNote practiceNote) { return new AdminPracticeLogResponseDto( missionLog.getId(), @@ -22,7 +29,7 @@ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNo missionLog.getUser().getEmail(), missionLog.getReferenceId(), practiceNote != null ? practiceNote.getTitle() : "-", - missionLog.getPoint(), + missionLog.getMissionType().getPoint(), missionLog.getCreatedAt() ); } diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java index 7eb840a2..44f9d1b3 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java +++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java @@ -108,6 +108,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/folders/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/fileUpload/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/practiceNotes/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") + .requestMatchers("/api/notices/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .requestMatchers("/api/study-room/**", "/api/study-rooms/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") .anyRequest().authenticated() ) @@ -118,8 +119,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .successHandler((request, response, authentication) -> { CustomAdminService userDetails = (CustomAdminService) authentication.getPrincipal(); Long adminId = userDetails.getUserId(); + // createAccessToken 은 이미 "Bearer " 접두사를 포함해 반환한다 + // (JwtTokenizer.BEARER_PREFIX). 여기서 한 번 더 붙이면 + // "Bearer Bearer eyJ..." 가 되어 JwtTokenFilter 가 앞 7글자만 떼고 + // 파싱에 실패해 401 이 난다. 프론트에 내려가는 토큰 형식이 이미 + // 접두사를 포함한 계약이므로 createAccessToken 쪽은 그대로 둔다. String token = jwtTokenizer.createAccessToken(String.valueOf(adminId), Map.of("authority", Authority.ROLE_ADMIN)); - response.setHeader("Authorization", "Bearer " + token); + response.setHeader("Authorization", token); response.sendRedirect(siteUrl + "/admin/main"); // 성공 후 관리자 페이지로 이동 }) .failureHandler((request, response, exception) -> { diff --git a/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java b/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java index 4b2a5b97..321df4fa 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java +++ b/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java @@ -26,7 +26,12 @@ public class RefreshToken { @Column(nullable = false, length = 50) private Authority authority; - @Column(nullable = false) + /** + * 원본 JWT 를 그대로 담는다. 현재 발급 형태(authority·sub·iat·jti·exp)만으로 최대 244자라 + * 기본 길이(varchar 255)로는 클레임이 하나만 늘어도 넘친다. + * 저장이 잘리면 이후 갱신 요청이 세션을 찾지 못해 REFRESH_TOKEN_NOT_FOUND(1002) 가 된다. + */ + @Column(nullable = false, length = 512) private String refreshToken; public static RefreshToken from(Long userId, Authority authority, String refreshToken) { diff --git a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java index f5e508d1..75f1ff6e 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java @@ -10,7 +10,7 @@ public enum AuthErrorCase implements ErrorCase { INVALID_REFRESH_TOKEN(400, 1001, "유효하지 않은 리프레시토큰입니다."), - REFRESH_TOKEN_NOT_FOUND(404, 1002, "리프레시 토큰 정보를 찾을 수 없습니다."), + REFRESH_TOKEN_NOT_FOUND(401, 1002, "리프레시 토큰 정보를 찾을 수 없습니다."), INVALID_AUTHORITY(400, 1003, "유효하지 않은 권한입니다."), diff --git a/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java b/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java index 8aaf829e..e4120a5b 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java @@ -2,6 +2,10 @@ import com.aisip.OnO.backend.auth.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; import java.util.UUID; @@ -12,7 +16,14 @@ public interface RefreshTokenRepository extends JpaRepository findByRefreshToken(String refreshToken); - void deleteByUserId(Long userId); + /** + * 탈퇴한 사용자의 세션을 기기 수와 상관없이 한 번에 지운다. idx_refresh_token_user_id 를 탄다. + * 영속성 컨텍스트를 비우지 않는다. 탈퇴 경로가 아직 사용자 엔티티를 수정 중이라 비우면 그 변경이 사라진다. + */ + @Modifying + @Transactional + @Query("DELETE FROM RefreshToken r WHERE r.userId = :userId") + int deleteByUserId(@Param("userId") Long userId); void deleteByRefreshToken(String refreshToken); } diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java index a16c1d0c..0eb31b79 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Map; @@ -65,8 +66,11 @@ public TokenResponseDto refreshAccessToken(String refreshToken) { * ✅ 로그아웃 처리 (Redis에서 RefreshToken 삭제 및 AccessToken 블랙리스트 추가) */ public void logout(String accessToken, Long userId, String refreshToken) { + // 자신의 세션만 지울 수 있다. 토큰 문자열만 알면 남의 세션을 끊을 수 있으면 안 된다. if (refreshToken != null && !refreshToken.isBlank()) { - refreshTokenRepository.deleteByRefreshToken(refreshToken); + refreshTokenRepository.findByRefreshToken(refreshToken) + .filter(session -> session.getUserId().equals(userId)) + .ifPresent(refreshTokenRepository::delete); } // AccessToken을 블랙리스트에 추가 (만료된 토큰이면 블랙리스트 추가 안 함) try { @@ -84,4 +88,23 @@ public void logout(String accessToken, Long userId, String refreshToken) { log.info("userId: {} has logged out", userId); } + + /** + * ✅ 탈퇴한 사용자에게 이미 나간 액세스 토큰을 전부 막는다 + * + *

로그아웃 블랙리스트는 토큰 문자열이 키라 탈퇴 요청에 실린 그 한 장밖에 막지 못한다. + * 다른 기기가 들고 있는 토큰은 남고, 그 토큰으로 사용자 존재를 확인하지 않는 엔드포인트 + * (예: {@code POST /api/fcm/token})가 최대 30분간 그대로 동작한다. 그래서 사용자 단위로 막는다. + * + *

표식 수명은 액세스 토큰(30분)이 아니라 리프레시 토큰 유효 기간에 맞춘다. + * 탈퇴해도 {@code refresh_token} 행이 남아 있어 {@code POST /api/auth/refresh} 로 액세스 토큰을 + * 계속 새로 받을 수 있기 때문이다(별도 이슈). 30분짜리 표식은 그 시간이 지나면 다시 뚫린다. + * 표식은 탈퇴 한 건당 키 하나라 수명을 늘려도 비용이 거의 없다. + * + *

DB 를 쓰지 않으므로 호출자의 트랜잭션에 얹히지 않는다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void blacklistUserAccessTokens(Long userId) { + redisTokenService.blacklistUser(userId, jwtTokenizer.getRefreshTokenExpirationSeconds()); + } } diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java index ed68e51c..ddffb4fb 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java @@ -75,12 +75,26 @@ public String createRefreshToken(String subject, Map claims) { .compact(); } + /** + * 액세스 토큰을 검증한다. + * + *

만료만은 {@link ExpiredJwtException} 을 그대로 올려보낸다. + * JwtTokenFilter 가 이 예외를 잡아 {@code ACCESS_TOKEN_EXPIRED(1005)} 로 응답하고, + * 프론트는 1005 를 받아야 토큰 갱신을 시도한다. + * 만료를 ApplicationException 으로 감싸면 필터가 만료를 구분하지 못해 + * {@code AUTHENTICATION_FAILED(1007)} 로 나가고, 프론트는 갱신 없이 인증 실패로 처리한다. + */ public void validateAccessToken(String token) { try{ Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token); + } catch (ExpiredJwtException e) { + log.warn("엑세스 토큰 만료: {}", e.getMessage()); + throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); } catch (Exception e) { + // 만료가 아닌 실패(서명 불일치·형식 오류)까지 ACCESS_TOKEN_EXPIRED 로 뭉뚱그리면 + // 프론트가 갱신을 시도할 이유가 없는 토큰에도 갱신을 건다. log.warn("엑세스 토큰 검증 실패: {}", e.getMessage()); - throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); + throw new ApplicationException(AuthErrorCase.INVALID_ACCESS_TOKEN); } } diff --git a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java index bd638103..1cd9886f 100644 --- a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.common.aop; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.exception.HandledFailure; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; @@ -20,6 +21,14 @@ public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { return; } + // 호출한 쪽이 이미 처리하는 실패는 여기서 error 로 올리지 않는다 + if (ex instanceof HandledFailure) { + log.warn("Handled service failure - method: {}, reason: {}", + joinPoint.getSignature().toShortString(), + ex.getMessage()); + return; + } + if (MDC.get("traceId") != null) { log.debug("Service exception propagated to request handler - method: {}, exceptionType: {}", joinPoint.getSignature().toShortString(), diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java index 0832e53e..cdaa2df1 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java @@ -12,32 +12,55 @@ import org.springframework.stereotype.Component; import java.io.IOException; +import java.util.List; +import java.util.Set; @Component @RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + /** + * 인증 실패 응답을 쓰지 않을 공개 경로. 접두사가 아니라 정확 매칭이다. + * + *

과거에는 {@code /api/auth} 접두사로 판정해서 보호 대상인 + * {@code POST /api/auth/logout} 까지 딸려 들어갔다. 그 결과 인증이 없어도 + * 401 이 아니라 빈 200 이 나가서, 앱은 로그아웃에 성공했다고 믿는데 + * 서버는 세션도 블랙리스트도 건드리지 않았다. + * + *

{@code /api/auth} 아래에서 공개여야 하는 것은 아직 토큰이 없는 상태에서 + * 부르는 세 가지뿐이다. 게스트 로그인, 소셜 로그인과 가입, 토큰 갱신이다. + */ + private static final Set PUBLIC_PATHS = Set.of( + "/", + "/robots.txt", + "/home", + "/login", + "/grafana", + "/prometheus", + "/swagger-ui.html", + "/v3/api-docs", + "/api/auth/signup/guest", + "/api/auth/signup/member", + "/api/auth/refresh" + ); + + /** 하위 경로 전체가 공개인 것들. 반드시 {@code /} 로 끝내서 접두사가 옆 경로를 먹지 않게 한다. */ + private static final List PUBLIC_PATH_PREFIXES = List.of( + "/grafana/", + "/prometheus/", + "/swagger-ui/", + "/v3/api-docs/" + ); + private final ObjectMapper objectMapper; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - String requestURI = request.getRequestURI(); - if(requestURI.contains("/actuator/") || - requestURI.equals("/grafana") || - requestURI.startsWith("/grafana/") || - requestURI.equals("/prometheus") || - requestURI.startsWith("/prometheus/") || - requestURI.startsWith("/api/auth") || - requestURI.equals("/") || - requestURI.equals("/robots.txt") || - requestURI.equals("/home") || - requestURI.startsWith("/login") || - requestURI.startsWith("/swagger-ui") || - requestURI.startsWith("/v3/api-docs") - ) { + if (isPublicPath(request.getRequestURI())) { return; } + ErrorCase errorCase = resolveErrorCase(request); response.setContentType("application/json;charset=UTF-8"); @@ -45,6 +68,25 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A objectMapper.writeValue(response.getWriter(), CommonResponse.error(errorCase)); } + private boolean isPublicPath(String requestURI) { + if (requestURI == null) { + return false; + } + + // 서블릿 컨테이너의 getRequestURI() 에는 쿼리스트링이 없지만, + // 테스트나 다른 구현이 붙여 보내도 판정이 흔들리지 않게 잘라낸다. + int queryIndex = requestURI.indexOf('?'); + String path = queryIndex < 0 ? requestURI : requestURI.substring(0, queryIndex); + + if (path.contains("/actuator/")) { + return true; + } + if (PUBLIC_PATHS.contains(path)) { + return true; + } + return PUBLIC_PATH_PREFIXES.stream().anyMatch(path::startsWith); + } + private ErrorCase resolveErrorCase(HttpServletRequest request) { Object errorCase = request.getAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE); if (errorCase instanceof ErrorCase resolvedErrorCase) { diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java index ad7ed3cc..4489d0a7 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java @@ -3,9 +3,9 @@ import com.aisip.OnO.backend.auth.entity.Authority; import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.service.JwtTokenizer; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.util.redis.RedisTokenService; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -76,11 +76,24 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - // 3. 인증 정보 설정 + // 3. 클레임 해석 Claims claims = jwtTokenizer.getClaimsFromAccessToken(accessToken); Long userId = Long.valueOf(claims.getSubject()); Authority authority = Authority.valueOf(claims.get("authority", String.class)); + // 4. 탈퇴한 계정인지 확인 + // 액세스 토큰은 서명과 만료만으로 통과하므로, 탈퇴한 뒤에도 만료 전(최대 30분)까지는 + // 사용자 존재를 확인하지 않는 엔드포인트가 그대로 동작한다. 실제로 탈퇴 계정의 토큰으로 + // POST /api/fcm/token 이 200 이 나면서 지웠던 fcm_token 행이 되살아났다(#300). + // 매 요청마다 DB 로 사용자 존재를 확인하는 대신, 탈퇴 시점에 사용자 단위 블랙리스트를 + // 심어 두고 여기서 확인한다. 아래 블랙리스트 조회와 같은 Redis 경로라 DB 부하가 늘지 않는다. + if (redisTokenService.isUserBlacklisted(userId)) { + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.INVALID_ACCESS_TOKEN); + filterChain.doFilter(request, response); + return; + } + + // 5. 인증 정보 설정 List authorities = List.of(new SimpleGrantedAuthority(authority.name())); UsernamePasswordAuthenticationToken authentication = @@ -89,8 +102,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.getContext().setAuthentication(authentication); MDC.put("userId", String.valueOf(userId)); MDC.put("authority", authority.name()); - } catch (ExpiredJwtException e) { - request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); + } catch (ApplicationException e) { + // JwtTokenizer 가 ExpiredJwtException 을 ApplicationException 으로 바꿔 던지므로 + // 여기서 ExpiredJwtException 을 잡으면 영영 걸리지 않는다(과거 만료 토큰이 1005 대신 + // 1007 로 나가 프론트의 토큰 갱신 대신 강제 로그아웃이 발동하던 원인). + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, e.getErrorCase()); } catch (Exception e) { request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.AUTHENTICATION_FAILED); } diff --git a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java index c858f376..69436632 100644 --- a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java +++ b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java @@ -24,6 +24,7 @@ public class CustomEmojiValidator { "in_love_heart", "scared_dread", "star_eyes_excited", + "excited_sparkles", "frustrated_studying", "holding_pen", "reading_with_glasses", @@ -53,6 +54,8 @@ public class CustomEmojiValidator { "thumbs_up_wink", "thank_you_sign", "winking_fist", + "angry_on_fire", + "christmas_santa", "hi_greeting", "waving_hello", "cheers_beer", @@ -60,6 +63,7 @@ public class CustomEmojiValidator { "texting_heart", "puzzle_teamwork", "peeking_wall", + "peeking_pole", "blue_hoodie", "sleeping_blanket", "cozy_blanket", diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java index b03db3f8..7cc8129b 100644 --- a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java @@ -1,11 +1,14 @@ package com.aisip.OnO.backend.common.exception; import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import jakarta.validation.ConstraintViolationException; import org.slf4j.MDC; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.*; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; @@ -18,8 +21,11 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.multipart.MultipartException; import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @@ -43,11 +49,21 @@ public ResponseEntity handleApplicationException(ApplicationExce .body(commonResponse); } + /** + * 요청 본문 검증 실패 처리. + * + *

이전 시그니처는 첫 인자로 {@link BindingResult} 를 받고 있었다. @ExceptionHandler 는 + * BindingResult 를 인자로 지원하지 않아 핸들러 자체가 해석되지 못했고, 그 결과 모든 + * @Valid 검증 실패가 400 이 아니라 마지막 Exception 핸들러로 떨어져 500 으로 나갔다. + * 검증 실패는 클라이언트 입력 오류이므로 400 이어야 한다. + */ @ExceptionHandler(value = MethodArgumentNotValidException.class) - public ResponseEntity handleValidException(BindingResult bindingResult, - MethodArgumentNotValidException ex, + public ResponseEntity handleValidException(MethodArgumentNotValidException ex, WebRequest request) { - String message = bindingResult.getAllErrors().get(0).getDefaultMessage(); + BindingResult bindingResult = ex.getBindingResult(); + String message = bindingResult.getAllErrors().isEmpty() + ? "잘못된 요청입니다." + : bindingResult.getAllErrors().get(0).getDefaultMessage(); CommonResponse commonResponse = CommonResponse.error(400, message); putErrorMdc(400, ex); @@ -80,6 +96,94 @@ public ResponseEntity handleBadRequestException(Exception ex, We return handleSpringStatusException(ex, request, HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); } + /** + * 업로드 용량 초과는 사용자가 큰 사진을 고른 것뿐이지 서버 결함이 아니다. + * + *

{@link MaxUploadSizeExceededException} 은 {@code ErrorResponse} 가 아니라서 + * 마지막 Exception 핸들러로 떨어졌고, 그 결과 500 응답 + Discord 에러 알림까지 나갔다. + * 이미 정의돼 있던 {@link FileUploadErrorCase#FILE_SIZE_EXCEEDED}(400) 로 되돌린다. + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex, + WebRequest request) { + return handleFileUploadErrorCase(ex, FileUploadErrorCase.FILE_SIZE_EXCEEDED); + } + + /** + * 멀티파트 본문 자체가 깨진 요청도 클라이언트 오류다. 여기서 500 이 되면 + * 앱의 잘못된 업로드 재시도가 그대로 Discord 알림 폭주로 이어진다. + */ + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipartException(MultipartException ex, WebRequest request) { + return handleFileUploadErrorCase(ex, FileUploadErrorCase.FILE_UPLOAD_FAILED); + } + + private ResponseEntity handleFileUploadErrorCase(Exception ex, FileUploadErrorCase errorCase) { + HttpStatusCode status = HttpStatusCode.valueOf(errorCase.getHttpStatusCode()); + + putErrorMdc(errorCase.getErrorCode(), ex); + logByStatus(status, "Multipart exception handled", ex.getMessage(), ex); + + return ResponseEntity + .status(status) + .body(CommonResponse.error(errorCase)); + } + + /** + * DB 제약 조건 위반은 서버 결함이 아니라 클라이언트 입력 문제다. + * + *

핸들러가 없던 시절에는 태그 중복 생성({@code Duplicate entry ... for key 'tag.idx_tag_user_normalized'})과 + * 컬럼 길이 초과({@code Data truncation: Data too long for column})가 전부 500으로 나가면서 + * Discord 에러 알림까지 발송됐다. 원인별로 4xx로 분류해 응답한다. + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex, + WebRequest request) { + HttpStatus status = resolveDataIntegrityStatus(ex); + String message = resolveDataIntegrityMessage(ex, status); + CommonResponse commonResponse = CommonResponse.error(status.value(), message); + + putErrorMdc(status.value(), ex); + logByStatus(status, "Data integrity exception handled", rootCauseMessage(ex), ex); + + return ResponseEntity + .status(status) + .body(commonResponse); + } + + private HttpStatus resolveDataIntegrityStatus(DataIntegrityViolationException ex) { + if (ex instanceof DuplicateKeyException || isDuplicateEntry(ex)) { + return HttpStatus.CONFLICT; + } + return HttpStatus.BAD_REQUEST; + } + + private String resolveDataIntegrityMessage(DataIntegrityViolationException ex, HttpStatus status) { + if (status == HttpStatus.CONFLICT) { + return "이미 존재하는 데이터입니다."; + } + + String cause = rootCauseMessage(ex).toLowerCase(); + if (cause.contains("data too long") || cause.contains("data truncation")) { + return "입력값이 허용된 길이를 초과했습니다."; + } + if (cause.contains("cannot be null") || cause.contains("not-null")) { + return "필수 입력값이 누락되었습니다."; + } + return "요청 데이터가 저장 조건을 만족하지 않습니다."; + } + + private boolean isDuplicateEntry(DataIntegrityViolationException ex) { + String cause = rootCauseMessage(ex).toLowerCase(); + return cause.contains("duplicate entry") || cause.contains("unique constraint"); + } + + private String rootCauseMessage(DataIntegrityViolationException ex) { + Throwable cause = ex.getMostSpecificCause(); + String message = cause == null ? ex.getMessage() : cause.getMessage(); + return message == null ? "" : message; + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex, WebRequest request) { if (ex instanceof ErrorResponse errorResponse) { diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java b/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java new file mode 100644 index 00000000..e9f9c8ce --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.common.exception; + +/** + * 호출한 쪽이 이미 처리하기로 되어 있는 실패에 붙이는 표시. + * + * LoggingAspect 는 HTTP 요청 밖(RabbitMQ consumer, 배치 스레드)에서 올라온 예외를 + * "아무도 안 잡은 예외"로 보고 error 로 남기는데, consumer 가 잡아서 정상 종료시키는 + * 예외까지 Sentry 에 fatal 로 올라가 노이즈가 됐다 (Sentry JAVA-SPRING-BOOT-3A). + * 이 인터페이스를 구현하면 aspect 가 error 로 올리지 않는다. + */ +public interface HandledFailure { +} diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java index 2988cdce..8000c2fb 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java @@ -10,4 +10,7 @@ public @interface RateLimit { String key(); int limitPerDay() default 20; -} \ No newline at end of file + + /** 한도 초과 시 어떤 오류로 알릴지. 기본값을 두지 않아 붙일 때 반드시 고르게 한다. */ + RateLimitScope scope(); +} diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java index d4612dea..66081c15 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java @@ -1,10 +1,11 @@ package com.aisip.OnO.backend.common.ratelimit; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; -import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import lombok.RequiredArgsConstructor; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -17,9 +18,21 @@ public class RateLimitAspect { @Before("@annotation(rateLimit)") public void checkRateLimit(RateLimit rateLimit) { - Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long userId = resolveUserId(); if (!rateLimitService.tryConsume(rateLimit.key(), userId, rateLimit.limitPerDay())) { - throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + throw new ApplicationException(rateLimit.scope().getErrorCase()); } } + + /** + * 인증 정보가 없으면 사용자별 한도를 셀 수 없다. + * 예전에는 여기서 NPE/ClassCastException 이 나 500 으로 나갔으므로 401 로 명시한다. + */ + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Long userId)) { + throw new ApplicationException(AuthErrorCase.AUTHENTICATION_FAILED); + } + return userId; + } } diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java new file mode 100644 index 00000000..70ebb385 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 한도를 넘겼을 때 어떤 오류로 알릴지 고른다. + * + * 예전에는 RateLimitAspect 가 한도 종류와 무관하게 ANALYSIS_RATE_LIMIT_EXCEEDED 를 던져서, + * 이미지 업로드 한도를 넘긴 사용자에게 "AI 분석 일일 요청 횟수를 초과했습니다" 가 떴다. + */ +@Getter +@RequiredArgsConstructor +public enum RateLimitScope { + + FILE_UPLOAD(FileUploadErrorCase.UPLOAD_RATE_LIMIT_EXCEEDED), + + AI_ANALYSIS(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + + private final ErrorCase errorCase; +} diff --git a/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java b/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java index ccca4d01..f5058510 100644 --- a/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java +++ b/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java @@ -19,6 +19,11 @@ public CryptoConverter(CryptoService cryptoService) { @Override public String convertToDatabaseColumn(String attribute) { + // AttributeConverter 계약상 null 은 그대로 통과시켜야 한다. + // 예전에는 여기서 NPE 가 나 RuntimeException("암호화 오류") 로 감싸져 500 이 됐다. + if (attribute == null) { + return null; + } try { return cryptoService.encrypt(attribute); // ✅ static 제거된 메서드 사용 } catch (Exception e) { @@ -28,6 +33,9 @@ public String convertToDatabaseColumn(String attribute) { @Override public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } try { return cryptoService.decrypt(dbData); // ✅ static 제거된 메서드 사용 } catch (Exception e) { diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java new file mode 100644 index 00000000..012428d0 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.common.web; + +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * 앱이 보내온 버전. {@code major.minor.patch} 세 자리만 담는다. + * + *

프론트는 {@code pubspec.yaml} 의 {@code version} 을 그대로 {@code 4.0.0+70} 꼴로 보낸다. + * {@code +} 뒤의 빌드 번호는 버린다. 빌드 번호는 스토어에 올릴 때마다 올라가는 값이라 + * 같은 스토어 버전 안에서도 제각각이고, "이 버전부터 미션을 받을 수 있다" 를 가르는 데 쓸 수 없다. + * 기준을 빌드 번호까지 내리면 설정값을 스토어 제출마다 따라 고쳐야 한다. + * + *

읽지 못한 값은 예외 대신 빈 값으로 돌려준다. 이 값을 쓰는 쪽의 규칙이 "모르면 구버전" 이라 + * 파싱 실패는 사고가 아니라 정상 분기다. 헤더는 앱이 채우는 값이라 무엇이 들어올지 서버가 정할 수 없고, + * 여기서 예외를 던지면 XP 적립 경로 한가운데서 요청이 500 으로 죽는다. + */ +public record AppVersion(int major, int minor, int patch) implements Comparable { + + /** 세 자리 숫자만 받는다. {@code 4}, {@code 4.0}, {@code 4.0.x} 는 버전으로 보지 않는다. */ + private static final Pattern SEMVER = Pattern.compile("(\\d+)\\.(\\d+)\\.(\\d+)"); + + public static Optional parse(String raw) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + + String semver = raw.trim(); + + // 빌드 번호(4.0.0+70)와 프리릴리즈 꼬리(4.1.0-beta.1)를 떼어 낸다. + // 프리릴리즈는 지금 프론트가 쓰지 않지만, 붙어 오면 통째로 파싱 실패해 + // 내부 배포 빌드가 전부 구버전으로 떨어지는 것보다 앞자리만 읽는 편이 낫다. + int buildAt = semver.indexOf('+'); + if (buildAt >= 0) { + semver = semver.substring(0, buildAt); + } + int preReleaseAt = semver.indexOf('-'); + if (preReleaseAt >= 0) { + semver = semver.substring(0, preReleaseAt); + } + + var matcher = SEMVER.matcher(semver); + if (!matcher.matches()) { + return Optional.empty(); + } + + try { + return Optional.of(new AppVersion( + Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)), + Integer.parseInt(matcher.group(3)) + )); + } catch (NumberFormatException e) { + // 자리 수가 int 를 넘는 값. 정상 앱에서 나올 수 없지만 헤더는 위조할 수 있다. + return Optional.empty(); + } + } + + @Override + public int compareTo(AppVersion other) { + if (major != other.major) { + return Integer.compare(major, other.major); + } + if (minor != other.minor) { + return Integer.compare(minor, other.minor); + } + return Integer.compare(patch, other.patch); + } + + /** {@code other} 와 같거나 그보다 높은 버전인가. */ + public boolean isAtLeast(AppVersion other) { + return compareTo(other) >= 0; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java new file mode 100644 index 00000000..b9ab8d99 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java @@ -0,0 +1,64 @@ +package com.aisip.OnO.backend.common.web; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 지금 처리 중인 HTTP 요청의 {@code X-App-Version} 헤더를 읽는다. + * + *

프론트가 모든 요청에 {@code 4.0.0+70} 꼴로 붙인다(AI-SIP/OnO_FRONT#214). + * 버전을 못 읽으면 프론트는 헤더를 아예 보내지 않는다. 빈 문자열이나 {@code unknown} 은 오지 않는다. + * + *

왜 인자로 넘기지 않고 요청 스코프에서 꺼내는가. 이 값이 필요한 곳은 + * {@code MissionLogService.addPointToUser} 한 군데인데, 거기까지 가는 길이 + * 컨트롤러 → {@code ProblemService}/{@code PracticeNoteService}/{@code ProblemSolveService} → 적립 으로 + * 서너 단계다. 버전을 인자로 물려 내리면 그 경로의 메서드 시그니처가 전부 바뀌고, 적립과 아무 상관 없는 + * 중간 호출부까지 버전을 들고 다녀야 한다. 읽는 곳이 하나뿐인 값 때문에 호출 경로 전체를 오염시킬 이유가 없다. + * + *

왜 필터나 인터셉터 + 요청 스코프 빈을 새로 두지 않는가. {@code FrameworkServlet} 이 요청마다 + * {@code RequestContextHolder} 를 채우고 끝나면 지운다. 헤더를 읽기만 하면 되는 일에 등록 순서와 + * 생명주기를 가진 컴포넌트를 하나 더 얹을 이유가 없다. MockMvc 도 같은 경로를 타기 때문에 + * 통합 테스트에서 실제 헤더가 서비스까지 닿는지 그대로 확인된다. + * + *

HTTP 요청이 없는 곳에서 불려도 터지지 않는다. 요청이 없으면 예외를 던지는 + * {@code currentRequestAttributes()} 대신 {@code getRequestAttributes()} 를 쓴다. 지금 적립을 부르는 + * 경로는 전부 HTTP 요청 안이지만(Quartz 잡 5종과 RabbitMQ 소비자 4종 어디에서도 적립을 부르지 않는다), + * 나중에 배치나 비동기 경로가 하나 늘어도 조용히 "모르는 버전" 으로 떨어져야 한다. + * {@code @Async} 스레드처럼 요청 컨텍스트가 물려지지 않는 자리도 마찬가지다. + */ +@Component +public class AppVersionResolver { + + public static final String APP_VERSION_HEADER = "X-App-Version"; + + public Optional resolve() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (!(attributes instanceof ServletRequestAttributes servletAttributes)) { + return Optional.empty(); + } + + HttpServletRequest request = servletAttributes.getRequest(); + return AppVersion.parse(request.getHeader(APP_VERSION_HEADER)); + } + + /** + * 이번 요청이 {@code rawThreshold} 와 같거나 높은 버전의 앱에서 왔는가. + * + *

모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 기준값을 읽지 못했을 때도 같다. 설정 오타 하나로 + * 모든 요청이 갑자기 신버전 취급을 받는 것보다, 아무도 신버전이 아닌 쪽이 되돌리기 쉽다. + * + *

버전으로 동작을 가르는 곳이 늘어날 때 이 판정을 각자 들고 있으면 "모르면 구버전" 이라는 + * 규칙이 곳곳에서 조금씩 달라진다. 비교만 여기에 두고, 기준 버전과 그래서 무엇이 달라지는가는 + * 각 도메인이 정한다. 그래야 한 도메인의 설정이 다른 도메인의 동작을 끌고 가지 않는다. + */ + public boolean isAtLeast(String rawThreshold) { + return AppVersion.parse(rawThreshold) + .flatMap(threshold -> resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java index 97eb3ff0..82072fc8 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.config.rabbitmq; import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder; import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -75,6 +76,33 @@ public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( factory.setConcurrentConsumers(3); // 동시 처리 스레드 수 factory.setMaxConcurrentConsumers(10); // 최대 동시 처리 스레드 수 factory.setPrefetchCount(1); // 한 번에 가져올 메시지 수 + + // 재시도를 모두 소진한 메시지는 큐로 돌려보내지 않고 DLQ 로 보낸다. + // + // 이 팩토리는 직접 만든 빈이라 spring.rabbitmq.listener.simple.retry.* 설정이 적용되지 않는다. + // 그런데 defaultRequeueRejected 의 기본값이 true 라, 컨슈머가 예외를 던지면 메시지가 + // 곧바로 큐 맨 앞으로 돌아가 즉시 다시 전달됐다. 백오프도 최대 횟수도 없어서 + // 큐의 x-message-ttl(FCM·Discord 5분 / S3 10분 / GPT 30분)이 다 될 때까지 + // 같은 메시지를 쉬지 않고 반복 처리했다. + // + // 실제로 Discord 웹훅에서 이 조합이 장애로 이어졌다. 전송에 성공한 뒤 로깅에서 터진 + // 예외가 "전송 실패"로 잡히면서, 이미 나간 웹훅이 5분 동안 계속 재발송돼 + // 컨테이너 아웃바운드 네트워크를 고갈시켰다. GPT 분석은 같은 상황에서 30분 동안 + // OpenAI 를 다시 호출한다(비용·레이트리밋 직결). + factory.setDefaultRequeueRejected(false); + // + // 이 재시도는 같은 스레드 안에서 리스너를 다시 부르는 방식이라 브로커 재전달이 아니다. + // 몇 번째 시도인지는 메시지 본문이 아니라 RabbitRetryAttempts 로 읽는다. + factory.setAdviceChain(RetryInterceptorBuilder.stateless() + .maxAttempts(RabbitRetryAttempts.MAX_ATTEMPTS) + // 1초 → 2초 → 4초. 네트워크 순단이나 일시적 5xx 는 이 사이에 회복되고, + // 영구적 실패(잘못된 입력, 4xx)는 4초 안에 포기하고 DLQ 로 넘어간다. + .backOffOptions(1000, 2.0, 4000) + // 거절 예외 메시지에 실제로 실패한 시도 횟수를 남긴다. + .recoverer(new org.springframework.amqp.rabbit.retry.RejectAndDontRequeueRecoverer( + () -> "Retry Policy Exhausted - attempts: " + RabbitRetryAttempts.exhaustedAttempts())) + .build()); + return factory; } diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java new file mode 100644 index 00000000..c99403de --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.config.rabbitmq; + +import org.springframework.retry.RetryContext; +import org.springframework.retry.support.RetrySynchronizationManager; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * RabbitMQ 컨슈머의 처리 시도 횟수를 구한다. + * + *

재시도는 브로커 재전달이 아니라 리스너 컨테이너의 재시도 인터셉터({@link RabbitMQConfig} 의 advice chain)가 + * 같은 스레드 안에서 리스너를 다시 호출하는 방식이다. 그래서 메시지 본문은 첫 시도와 똑같고, + * 본문의 retryCount 를 올려도 다음 시도에 전달되지 않는다. 시도 횟수는 대신 아래에서 읽는다. + *

    + *
  • 처리 중: 재시도 인터셉터가 스레드에 걸어 두는 {@link RetryContext} 의 실패 횟수
  • + *
  • DLQ: 재시도를 소진한 메시지는 거절(reject)되어 브로커가 dead-letter 하면서 붙이는 {@code x-death} 헤더
  • + *
+ */ +public final class RabbitRetryAttempts { + + /** 첫 시도를 포함한 최대 시도 횟수. 리스너 컨테이너 재시도 정책과 DLQ 알림이 같은 값을 본다. */ + public static final int MAX_ATTEMPTS = 3; + + public static final String X_DEATH_HEADER = "x-death"; + + private static final String REJECTED = "rejected"; + private static final String EXPIRED = "expired"; + + private RabbitRetryAttempts() { + } + + /** + * 지금 처리 중인 시도가 몇 번째인지 돌려준다(1부터). + * 재시도 인터셉터 밖에서 호출되면(테스트의 직접 호출 등) 컨텍스트가 없으므로 1 로 본다. + */ + public static int currentAttempt() { + RetryContext context = RetrySynchronizationManager.getContext(); + return context == null ? 1 : context.getRetryCount() + 1; + } + + /** + * 재시도를 모두 소진한 시점(recoverer)에서 실패한 시도 횟수를 설명한다. + * recoverer 는 재시도 컨텍스트가 닫히기 전에 불리므로 실패 횟수가 그대로 남아 있다. + */ + static String exhaustedAttempts() { + RetryContext context = RetrySynchronizationManager.getContext(); + return context == null ? "unknown" : context.getRetryCount() + "/" + MAX_ATTEMPTS; + } + + /** + * DLQ 로 들어온 메시지가 원래 큐에서 몇 번 시도됐는지 {@code x-death} 헤더로 설명한다. + * + *

{@code rejected} 는 재시도 인터셉터가 {@link #MAX_ATTEMPTS} 번을 모두 실패한 뒤 거절한 경우다. + * 같은 큐에서 여러 번 dead-letter 됐다면(DLQ 메시지를 원래 큐로 되돌려 다시 실패한 경우) count 만큼 곱한다. + * {@code expired} 는 큐 TTL 이 지나 처리 전에 빠진 경우라 시도 횟수를 알 수 없다. + * + * @param xDeath {@code x-death} 헤더 값. 헤더가 없으면 null + * @param originalQueue 메시지가 원래 들어 있던 큐 이름 + */ + public static String describeDeadLetter(List> xDeath, String originalQueue) { + if (xDeath == null || xDeath.isEmpty()) { + return "알 수 없음 (x-death 헤더 없음)"; + } + + long rejectedCount = 0; + String latestReason = null; + for (Map death : xDeath) { + if (death == null || !Objects.equals(originalQueue, String.valueOf(death.get("queue")))) { + continue; + } + String reason = String.valueOf(death.get("reason")); + // x-death 는 최근 dead-letter 가 앞에 온다. + if (latestReason == null) { + latestReason = reason; + } + if (REJECTED.equals(reason) && death.get("count") instanceof Number count) { + rejectedCount = count.longValue(); + } + } + + if (latestReason == null) { + return "알 수 없음 (" + originalQueue + " 의 x-death 기록 없음)"; + } + if (rejectedCount > 0) { + String repeated = rejectedCount > 1 ? ", DLQ 이동 " + rejectedCount + "번" : ""; + return (rejectedCount * MAX_ATTEMPTS) + "회 (재시도 소진" + repeated + ")"; + } + if (EXPIRED.equals(latestReason)) { + return "알 수 없음 (큐 TTL 만료로 이동)"; + } + return "알 수 없음 (사유: " + latestReason + ")"; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java index 85e88dcb..9865f091 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java @@ -32,21 +32,42 @@ public DiscordWebhookConsumer() { @RabbitListener(queues = RabbitMQConfig.DISCORD_WEBHOOK_QUEUE, concurrency = "1-3") public void handleWebhookMessage(DiscordWebhookMessage message) { DiscordWebhookPayload payload = message.getPayload(); + // 본문 없는 메시지는 재시도해도 보낼 것이 없다. 재시도/DLQ 없이 종료(ACK). + if (payload == null) { + log.warn("Discord webhook 메시지에 payload 가 없어 건너뜀. dedupKey={}", message.getDedupKey()); + return; + } + try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); restTemplate.postForEntity(webhookUrl, new HttpEntity<>(payload, headers), String.class); - log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); } catch (Exception e) { - log.error("Discord webhook 전송 실패: {}", e.getMessage(), e); + // 일시적 실패(read timeout 등)까지 error 로 올리면 재시도로 성공한 건도 Sentry 에 쌓인다 + // (Sentry JAVA-SPRING-BOOT-5C). 예외 메시지에는 웹훅 URL(토큰 포함)이 그대로 들어 있어 + // 메시지 대신 예외 타입만 남긴다. + // + // 주의: 현재 listener 재시도 설정이 없어 예외를 던지면 백오프 없이 무한 재큐된다. + // DLQ 로는 큐 TTL(5분) 만료로만 빠진다. 이 구조 자체는 이슈 #229 에서 따로 다룬다 + log.warn("Discord webhook 전송 실패 - exceptionType: {}", e.getClass().getSimpleName()); throw new RuntimeException("Discord webhook 전송 실패", e); } + + // 전송 성공 후의 로깅은 try 밖에서 한다. embeds 가 비어 있을 때 나던 IndexOutOfBoundsException 이 + // catch 로 잡혀 "전송 실패"로 둔갑하면, 이미 나간 웹훅이 재시도로 중복 발송된다. + log.info("Discord webhook 전송 완료: {}", extractTitle(payload)); } @RabbitListener(queues = RabbitMQConfig.DISCORD_WEBHOOK_DLQ) public void handleWebhookDLQ(DiscordWebhookMessage message) { - DiscordWebhookPayload payload = message.getPayload(); - String title = payload.embeds().isEmpty() ? "(제목 없음)" : payload.embeds().get(0).title(); - log.error("Discord webhook DLQ — 최종 전송 실패, 수동 확인 필요. title={}", title); + log.error("Discord webhook DLQ — 최종 전송 실패, 수동 확인 필요. title={}", + extractTitle(message.getPayload())); + } + + private String extractTitle(DiscordWebhookPayload payload) { + if (payload == null || payload.embeds() == null || payload.embeds().isEmpty()) { + return "(제목 없음)"; + } + return payload.embeds().get(0).title(); } } diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java index db887b34..426b82bc 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.FcmNotificationMessage; import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; @@ -15,9 +16,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; /** * FCM 푸시 알림 메시지 Consumer @@ -41,18 +44,18 @@ public class FcmNotificationConsumer { */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_QUEUE, concurrency = "5-15") public void handleNotificationMessage(FcmNotificationMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, userId: {}, messageRetryCount: {}", + log.info("RabbitMQ message received - queue: {}, operation: {}, userId: {}, attempt: {}/{}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", - message.getUserId(), message.getRetryCount()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); try { // 사용자의 모든 FCM 토큰 조회 List userFcmTokenList = fcmTokenRepository.findAllByUserId(message.getUserId()); if (userFcmTokenList.isEmpty()) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, userId: {}, attempt: {}/{}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "token_not_found", - message.getUserId(), message.getRetryCount()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; // 토큰 없으면 스킵 (정상 처리) } @@ -91,9 +94,9 @@ public void handleNotificationMessage(FcmNotificationMessage message) { message.getUserId(), successCount, failCount); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, userId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "failure", - message.getUserId(), message.getRetryCount(), e.getMessage()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("FCM 푸시 알림 전송 실패: " + message.getUserId(), e); @@ -105,14 +108,20 @@ public void handleNotificationMessage(FcmNotificationMessage message) { */ private void sendToDevice(String token, FcmNotificationMessage message) throws FirebaseMessagingException { Timer.Sample sample = Timer.start(meterRegistry); - Message fcmMessage = Message.builder() + Message.Builder builder = Message.builder() .setToken(token) .setNotification(Notification.builder() .setTitle(message.getTitle()) .setBody(message.getBody()) - .build()) - .putAllData(message.getData()) - .build(); + .build()); + + // putAllData 는 null 을 그대로 putAll 해서 NPE 를 낸다. data 없는 메시지도 알림 자체는 나가야 하므로 + // null 이면 데이터 없이 보낸다. (없으면 모든 디바이스 전송이 실패 처리되어 무의미한 재시도 후 DLQ 로 간다) + if (message.getData() != null) { + builder.putAllData(message.getData()); + } + + Message fcmMessage = builder.build(); try { String messageId = firebaseMessaging.send(fcmMessage); @@ -131,20 +140,23 @@ private void sendToDevice(String token, FcmNotificationMessage message) throws F * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_DLQ) - public void handleNotificationDLQ(FcmNotificationMessage message) { - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + public void handleNotificationDLQ(FcmNotificationMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.FCM_NOTIFICATION_QUEUE); + + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, userId: {}, attempts: {}", RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", "dlq", - message.getUserId(), message.getRetryCount()); + message.getUserId(), attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 FCM 푸시 알림 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**User ID:** %d\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**User ID:** %d\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. FCM 토큰을 확인하거나 수동으로 알림을 재전송해주세요.", RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", message.getUserId(), - message.getRetryCount() + attempts ); try { diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java index 9feb94f2..f9a8125b 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.ProblemAnalysisMessage; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import com.aisip.OnO.backend.problem.service.ProblemAnalysisFailureService; @@ -11,8 +12,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; + /** * GPT 문제 분석 메시지 Consumer * - RabbitMQ에서 메시지를 받아 GPT 분석 처리 @@ -34,8 +39,17 @@ public class ProblemAnalysisConsumer { */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_QUEUE, concurrency = "1-2") public void handleAnalysisMessage(ProblemAnalysisMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", - RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, attempt: {}/{}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); + + // problemId 없는 메시지는 재시도해도 조회 자체가 불가능하다. 재시도/DLQ 알림 없이 종료(ACK). + if (message.getProblemId() == null) { + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, attempt: {}/{}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "problem_id_missing", + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); + return; + } try { // 실제 GPT 분석 수행 @@ -45,30 +59,30 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "success", message.getProblemId()); } catch (NonRetryableAnalysisException e) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, reason: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, reason: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "non_retryable_failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 상태는 Service에서 FAILED로 업데이트됨. 재큐잉 없이 종료(ACK) return; } catch (ApplicationException e) { if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND || e.getErrorCase() == ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "resource_not_found", - message.getProblemId(), message.getRetryCount()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; } - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("GPT 문제 분석 실패: " + message.getProblemId(), e); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("GPT 문제 분석 실패: " + message.getProblemId(), e); @@ -81,20 +95,23 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_DLQ) - public void handleAnalysisDLQ(ProblemAnalysisMessage message) { - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + public void handleAnalysisDLQ(ProblemAnalysisMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.GPT_ANALYSIS_QUEUE); + + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, attempts: {}", RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", "dlq", - message.getProblemId(), message.getRetryCount()); + message.getProblemId(), attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 GPT 문제 분석 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. 문제를 확인하고 수동으로 재분석을 요청해주세요.", RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", message.getProblemId(), - message.getRetryCount() + attempts ); // DLQ 도달 = 모든 재시도 소진. PROCESSING 고착 방지를 위해 FAILED로 전이 diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java index 7bf921fb..5bbd9e1e 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java @@ -1,14 +1,19 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.S3DeleteMessage; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; + /** * S3 파일 삭제 메시지 Consumer * - RabbitMQ에서 메시지를 받아 S3 파일 삭제 처리 @@ -29,8 +34,18 @@ public class S3DeleteConsumer { */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_QUEUE, concurrency = "3-10") public void handleS3DeleteMessage(S3DeleteMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", - RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, attempt: {}/{}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", message.getProblemId(), + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); + + // 삭제 대상 URL 이 없으면 재시도해도 영원히 실패한다(FileUploadService 가 NPE). + // 재시도 소진 후 DLQ 알림까지 울리는 것을 막기 위해 여기서 정상 종료(ACK)한다. + if (message.getImageUrl() == null || message.getImageUrl().isBlank()) { + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "image_url_missing", + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); + return; + } try { // S3 파일 삭제 실행 @@ -40,9 +55,9 @@ public void handleS3DeleteMessage(S3DeleteMessage message) { RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "success", message.getProblemId()); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("S3 파일 삭제 실패 - problemId: " + message.getProblemId(), e); @@ -55,23 +70,26 @@ public void handleS3DeleteMessage(S3DeleteMessage message) { * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_DLQ) - public void handleS3DeleteDLQ(S3DeleteMessage message) { + public void handleS3DeleteDLQ(S3DeleteMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.S3_DELETE_QUEUE); + String maskedObjectKey = maskS3ObjectKey(message.getImageUrl()); - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, objectKey: {}, messageRetryCount: {}", + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, objectKey: {}, attempts: {}", RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", "dlq", - message.getProblemId(), maskedObjectKey, message.getRetryCount()); + message.getProblemId(), maskedObjectKey, attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 S3 파일 삭제 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Object Key:** %s\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Object Key:** %s\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. 수동으로 S3에서 파일을 삭제하거나 메시지를 재처리해주세요.", RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", message.getProblemId(), maskedObjectKey, - message.getRetryCount() + attempts ); try { diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java index c0e5bc82..dac0d282 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java @@ -19,6 +19,12 @@ public class FcmNotificationMessage implements Serializable { private String title; private String body; private Map data; + /** + * 사용하지 않는다. 항상 0 이다. + * 재시도는 리스너 컨테이너가 같은 메시지로 다시 호출하는 방식이라 본문 값을 올려도 다음 시도에 전달되지 않는다. + * 시도 횟수는 {@link com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts} 로 읽는다. + * 메시지 JSON 모양을 바꾸지 않으려고 남겨 둔다. 롤링 배포 중 구버전과 신버전이 섞여도 같은 모양을 주고받는다. + */ private int retryCount; public FcmNotificationMessage(Long userId, String title, String body, Map data) { @@ -28,8 +34,4 @@ public FcmNotificationMessage(Long userId, String title, String body, Map사용자 식별자는 요청 본문이 아니라 인증 컨텍스트에서만 꺼낸다. 본문으로 받으면 + * 남의 장착 상태를 바꾸는 요청을 그대로 받아들이게 된다. + * + *

{@code /api/cosmetics} 는 SecurityConfig 의 별도 매처에 걸리지 않아 + * {@code anyRequest().authenticated()} 를 탄다. 비로그인 요청은 401 이다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/cosmetics") +public class CosmeticController { + + private final CosmeticService cosmeticService; + + @GetMapping("") + public CommonResponse getCosmetics() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.getCosmetics(userId)); + } + + /** + * 슬롯 하나 장착. {@code itemKey} 가 null 이면 해제한다. + * + *

같은 슬롯을 다시 걸어도 같은 결과가 되는 멱등 연산이라 POST 가 아니라 PUT 이다. + */ + @PutMapping("/equip") + public CommonResponse equip(@Valid @RequestBody CosmeticEquipRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equip(userId, request.slot(), request.itemKey())); + } + + /** + * 차림 전체 저장. 꾸미기 화면의 저장 버튼 한 번이 이 요청 하나다. + * + *

전체 교체라 본문에 없는 슬롯은 비운다. 빈 맵이면 전부 벗는다. + * 한 건이라도 검증에 걸리면 한 트랜잭션이 통째로 거절돼 반쪽 차림이 남지 않는다. + * + *

같은 본문을 두 번 보내도 결과가 같은 멱등 연산이라 POST 가 아니라 PUT 이다. + */ + @PutMapping("/equip-all") + public CommonResponse equipAll(@Valid @RequestBody CosmeticEquipAllRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equipAll(userId, request.equipped())); + } + + @PutMapping("/equip-set") + public CommonResponse equipSet(@Valid @RequestBody CosmeticEquipSetRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equipSet(userId, request.setId())); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java new file mode 100644 index 00000000..b863f79c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +/** + * 차림 전체 저장 요청. 꾸미기 화면의 저장 버튼 한 번이 이 본문 하나로 나간다. + * + *

{@code equipped} 는 바뀐 슬롯이 아니라 저장할 차림 전부다. 여기 없는 슬롯은 비운다. + * "안 보낸 것은 그대로 둔다" 로 하면 시착 화면에서 벗어 놓고 저장한 것을 표현할 방법이 없어진다. + * + *

그래서 빈 맵은 유효한 요청이다. 전부 벗는다는 뜻이라 {@code @NotEmpty} 가 아니라 + * {@code @NotNull} 이다. 필드 자체가 빠진 본문(맵이 null)은 "전부 벗기" 인지 "보내다 만 것" 인지 + * 구별할 수 없어 거절한다. + * + *

값이 {@code null} 인 항목은 그 슬롯을 비우라는 뜻으로, 키를 아예 빼고 보낸 것과 같게 다룬다. + * 프론트가 빈 자리를 {@code null} 로 채워 보내든 빼고 보내든 같은 결과가 되어야 한다. + * + *

사용자 식별자는 여기에 담지 않는다. 본문으로 받으면 남의 차림을 바꾸는 요청을 그대로 받아들이게 + * 된다. 다른 꾸미기 API 와 같이 인증 컨텍스트에서만 꺼낸다. + */ +public record CosmeticEquipAllRequestDto( + @NotNull(message = "장착 상태를 지정해야 합니다.") + Map equipped +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java new file mode 100644 index 00000000..098bf535 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java @@ -0,0 +1,21 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import jakarta.validation.constraints.NotNull; + +/** + * 슬롯 하나 장착/해제 요청. + * + *

{@code itemKey} 가 null 이면 그 슬롯을 해제한다. 그래서 이 필드에는 {@code @NotNull} 이 없다. + * + *

{@code slot} 은 아이템에서 유도할 수도 있지만 굳이 함께 받는다. 프론트가 "머리 슬롯을 바꾼다"고 + * 보낸 요청이 서버에서 엉뚱한 슬롯을 건드리면 사용자 눈에는 원인 없는 버그로 보인다. + * 둘이 어긋나면 조용히 아이템 쪽을 따르지 않고 400 으로 거절한다. 해제 요청은 아이템이 없으므로 + * 이 값이 유일한 대상 지정 수단이기도 하다. + */ +public record CosmeticEquipRequestDto( + @NotNull(message = "슬롯을 지정해야 합니다.") + CosmeticSlot slot, + String itemKey +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java new file mode 100644 index 00000000..98cae771 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +import java.util.List; +import java.util.Map; + +/** + * 장착 결과. + * + *

바뀐 슬롯만이 아니라 갱신된 장착 상태 전체를 돌려준다. 프론트가 자기 쪽 상태를 + * 직접 계산해 맞추면, 충돌 자동 해제처럼 서버만 아는 규칙이 생길 때마다 양쪽이 어긋난다. + * 서버가 만든 결과를 그대로 그리면 그 종류의 불일치가 없어진다. + * + * @param equipped 슬롯 이름 → 아이템 키. 비어 있는 슬롯은 키 자체가 없다. + * @param unequippedSlots 이번 요청 때문에 자동으로 벗겨진 슬롯. 사용자가 직접 벗긴 것이 아니므로 + * 프론트가 "가디건이 벗겨졌어요" 같은 안내를 띄울 수 있도록 따로 알려 준다. + * 충돌이 없으면 빈 배열이다. + */ +public record CosmeticEquipResponseDto( + Map equipped, + List unequippedSlots +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java new file mode 100644 index 00000000..6c444174 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 세트 장착 요청. 세트에 속한 아이템을 각자의 슬롯에 한 번에 건다. */ +public record CosmeticEquipSetRequestDto( + @NotBlank(message = "세트를 지정해야 합니다.") + String setId +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java new file mode 100644 index 00000000..22be8261 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java @@ -0,0 +1,59 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticUnlockLevels; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; + +import java.util.List; + +/** + * 아이템 한 건. + * + *

{@code owned} 는 저장된 값이 아니라 조회 시점에 계산한 값이다. {@code requiredAbility} 가 + * 있으면 그 능력치 레벨과, 없으면 총 학습 레벨과 {@code requiredLevel} 을 비교한다. + * 그런데도 목록으로 실어 보내는 이유는, 나중에 시즌 보상이나 이벤트로 획득 경로가 늘어도 + * 프론트는 이 불리언만 보면 되게 하기 위해서다. 해금 규칙이 바뀌어도 앱은 그대로다. + * + *

{@code requiredAbility} 를 함께 내려보내는 이유는 화면 문구 때문이다. 이 값이 없으면 + * 잠긴 아이템에 "Lv.5 에 열려요" 까지만 쓸 수 있고 어느 레벨을 올려야 하는지는 쓸 수 없다. + * + *

{@code layerOrder} 는 이 아이템만의 그리는 층이다. {@code null} 이면 자리의 기본값을 쓴다. + * 프론트가 {@code item.layerOrder ?? slot.layerOrder} 로 푼다. 자리 하나에 층이 둘인 것은 + * 지금 {@code BAG}(등에 메는 가방 200 / 앞으로 메는 가방 450) 뿐이다. + * + *

필드는 더하기만 했다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. + * JSON 필드 순서는 계약이 아니다. + */ +public record CosmeticItemResponseDto( + String itemKey, + CosmeticSlot slot, + Integer layerOrder, + String nameKo, + String imageUrl, + Integer requiredLevel, + AbilityType requiredAbility, + boolean fullBody, + String setId, + String setNameKo, + List conflictsWith, + boolean owned +) { + + public static CosmeticItemResponseDto of(CosmeticItem item, CosmeticUnlockLevels levels) { + return new CosmeticItemResponseDto( + item.getItemKey(), + item.getSlot(), + item.getLayerOrder(), + item.getNameKo(), + item.getImageUrl(), + item.getRequiredLevel(), + item.getRequiredAbility(), + item.isFullBody(), + item.getSetId(), + item.getSetNameKo(), + item.conflictKeys(), + item.isOwnedBy(levels) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java new file mode 100644 index 00000000..428b612b --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +import java.util.List; +import java.util.Map; + +/** + * {@code GET /api/cosmetics} 응답. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. + * + * @param baseImageUrl 개구리 본체 이미지. 슬롯 아이템이 아니라 항상 그려진다. + * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 등에 메는 가방(200)과 옷(400) 사이에 들어간다. + * 슬롯 목록에는 본체가 없으므로 이 값이 따로 있어야 프론트가 레이어 순서를 + * 코드에 박지 않고 조립할 수 있다. + * @param slots 장착 가능한 슬롯과 그리는 순서. 뒤에서 앞 순서로 정렬돼 온다. + * {@code composited: false} 인 자리(FRAME)는 개구리 합성에서 빼야 한다. + * @param items 활성 아이템 전부. 잠긴 것도 {@code owned: false} 로 함께 내려간다. + * 화면에 "레벨 6 에 열림" 을 보여주려면 잠긴 것도 알아야 한다. + * @param equipped 슬롯 이름 → 아이템 키. 비어 있는 슬롯은 키 자체가 없다. + */ +public record CosmeticListResponseDto( + String baseImageUrl, + int baseLayerOrder, + List slots, + List items, + Map equipped +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java new file mode 100644 index 00000000..606b46ce --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java @@ -0,0 +1,26 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +/** + * 슬롯 하나와 그리는 순서. + * + *

순서를 응답에 실어 보내는 이유는 프론트가 레이어 순서를 코드에 박지 않게 하기 위해서다. + * 슬롯이 늘거나 순서가 바뀌어도 이미 깔린 앱이 새 순서를 따른다. + * + *

{@code composited} 는 그 자리를 개구리 그림에 겹쳐 그리는지다. {@code FRAME} 만 false 다. + * 원형 프로필 사진의 테두리라 옷장 목록에는 나가지만 개구리 합성에서는 빠진다. + * 이 값 없이 {@code layerOrder} 만 내려보내면 프론트는 순서대로 겹치는 수밖에 없어 + * 프레임이 개구리 위에 덮인다. 뒤에 덧붙인 필드라 구버전 앱은 그대로 돈다. + */ +public record CosmeticSlotDto( + CosmeticSlot slot, + int layerOrder, + String nameKo, + boolean composited +) { + + public static CosmeticSlotDto from(CosmeticSlot slot) { + return new CosmeticSlotDto(slot, slot.getLayerOrder(), slot.getNameKo(), slot.isComposited()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java new file mode 100644 index 00000000..3e65bacc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java @@ -0,0 +1,26 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +/** + * 레벨업으로 이번에 열린 아이템. 보상 수령 응답에 실려 "새 아이템이 열렸어요" 화면을 띄우는 데 쓴다. + * + *

{@code owned} 를 담지 않는다. 여기 실렸다는 것 자체가 방금 열렸다는 뜻이다. + */ +public record UnlockedCosmeticDto( + String itemKey, + String nameKo, + CosmeticSlot slot, + String imageUrl +) { + + public static UnlockedCosmeticDto from(CosmeticItem item) { + return new UnlockedCosmeticDto( + item.getItemKey(), + item.getNameKo(), + item.getSlot(), + item.getImageUrl() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java new file mode 100644 index 00000000..1407204d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java @@ -0,0 +1,195 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * 꾸미기 아이템 하나. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. + * + *

애플리케이션은 이 테이블을 읽기만 한다. 그래서 {@code BaseEntity} 를 상속하지 않는다. + * 상속하면 쓰지도 않는 {@code deleted_at} 이 딸려 와 V34 스키마와 어긋난다. + * {@code createdAt}/{@code updatedAt} 은 시드가 채우는 값을 그대로 읽기만 한다. + * + *

해금 조건은 {@code requiredLevel} 과 {@code requiredAbility} 두 컬럼이 함께 정한다. + * 자세한 것은 {@link #isOwnedBy(CosmeticUnlockLevels)} 에 있다. + * + *

{@code imageUrl} 은 지금 번들 상대 경로({@code assets/Cosmetic/hat_beanie.png})다. + * S3 로 옮길 때 이 컬럼만 {@code https://...} 로 바꾸면 앱 배포 없이 전환된다. + * 프론트가 {@code http} 로 시작하는지로 갈라 처리한다. + */ +@Entity +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "cosmetic_item", + uniqueConstraints = @UniqueConstraint(name = "uk_cosmetic_item_key", columnNames = "item_key"), + indexes = { + // 레벨업 해금 알림이 "levelBefore < required_level <= levelAfter 인 활성 아이템" 을 찾는다. + @Index(name = "idx_cosmetic_item_active_level", columnList = "active, required_level"), + @Index(name = "idx_cosmetic_item_set", columnList = "set_id") + }) +public class CosmeticItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "item_key", nullable = false, length = 64) + private String itemKey; + + /** + * {@code columnDefinition} 을 못 박는 이유는 V34 의 {@code slot VARCHAR(32)} 과 맞추기 위해서다. + * + *

비워 두면 Hibernate 는 MySQL 에서 이 컬럼을 네이티브 {@code ENUM(...)} 으로 만든다. + * 그러면 운영 스키마(VARCHAR)와 테스트 스키마(ENUM)가 갈리고, 이 enum 에서 상수를 하나 지우는 + * 순간 그 값을 담고 있던 옛 마이그레이션이 테스트에서만 "Data truncated" 로 죽는다. + * 운영에서는 VARCHAR 라 아무 일도 없는데 테스트만 터지는 차이는 만들지 않는다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 32, columnDefinition = "varchar(32)") + private CosmeticSlot slot; + + /** + * 이 아이템만의 그리는 층. {@code null} 이면 자리의 기본값({@link CosmeticSlot#getLayerOrder()})을 쓴다. + * + *

{@link CosmeticSlot#BAG} 하나를 위해 생겼다. 등에 메는 가방과 앞으로 메는 가방은 같은 자리인데 + * 그리는 층이 다르다. 전자는 개구리 본체보다 뒤(200), 후자는 옷 위(450)다. + * 층이 다르다는 이유만으로 자리를 둘로 나누면 아이템 두세 개짜리 탭이 하나 더 생긴다. + * + *

0 이나 -1 같은 마법값을 쓰지 않는다. {@code required_level} 과 같은 이유로, + * "덮어쓰지 않는다" 와 "0층에 그린다" 는 다른 말이다. + * + *

서버는 이 값을 풀지 않고 그대로 내려보낸다. 합성은 프론트가 하고 + * {@code item.layerOrder ?? slot.layerOrder} 로 푼다. + */ + @Column(name = "layer_order") + private Integer layerOrder; + + @Column(name = "name_ko", nullable = false, length = 64) + private String nameKo; + + @Column(name = "image_url", nullable = false, length = 512) + private String imageUrl; + + /** + * 이 레벨부터 열린다. {@code null} 이면 레벨로는 열리지 않는다. + * + *

0 이나 -1 같은 마법값을 쓰지 않는다. "레벨 조건이 없다"와 "레벨 0 이면 된다"는 다른 말이고, + * 마법값을 쓰면 {@code required_level <= level} 비교가 조용히 전부 참이 된다. + */ + @Column(name = "required_level") + private Integer requiredLevel; + + /** + * {@code required_level} 을 어느 레벨과 비교할지. {@code null} 이면 총 학습 레벨이다. + * + *

테마 해금이 이미 능력치별로 돌고 있어 같은 방식을 쓴다. 총 학습 레벨 하나로만 열면 + * 앱의 능력치 네 칸짜리 스탯 화면이 보상과 아무 관계가 없어지고, 사용자에게 + * "출석 Lv.5 달성" 같은 구체적인 조건을 보여 줄 수도 없다. + * + *

여기가 {@code null} 이면 "능력치 조건이 없다"는 뜻이지 "ATTENDANCE 를 본다"는 뜻이 아니다. + * 기본값을 넣지 않는 이유는 {@code required_level} 에 마법값을 쓰지 않는 것과 같다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "required_ability", length = 32) + private AbilityType requiredAbility; + + /** + * 소매와 바짓단까지 그려진 전신 의상인지. + * + *

앱이 이 옷을 입히면 개구리 본체를 머리만 있는 그림으로 바꿔 깐다. 그러지 않으면 + * 옷 밑으로 본체의 팔다리가 삐져나온다. 어느 옷이 전신인지는 그림을 봐야 알 수 있는 사실이라 + * 프론트가 item_key 목록을 코드에 박지 않도록 카탈로그가 들고 내려간다. + * + *

{@code @ColumnDefault} 가 V36 의 {@code DEFAULT 0} 과 짝을 이룬다. 이 값이 없으면 + * Hibernate 가 만드는 스키마에만 기본값이 빠져, 이 컬럼을 모르던 시절의 시드 INSERT(V35)를 + * 다시 돌릴 때 "doesn't have a default value" 로 막힌다. 운영에서는 V35 가 V36 보다 먼저 돌아 + * 드러나지 않는 차이라, 맞춰 두지 않으면 테스트에서만 나는 실패가 된다. + */ + @ColumnDefault("0") + @Column(name = "full_body", nullable = false) + private boolean fullBody; + + @Column(name = "set_id", length = 64) + private String setId; + + /** + * 세트의 사람이 읽는 이름. 세트에 속하지 않으면 {@code null} 이다. + * + *

{@code set_id} 는 기계용 키라 화면에 그대로 쓸 수 없다. 이 컬럼이 없던 동안 + * 프론트가 세트 배너에 아이템 이름을 이어 붙여 썼다. + * + *

같은 {@code set_id} 를 가진 행끼리 값이 같아야 한다. 세트를 늘릴 때 한 행만 고치면 + * 배너 이름이 아이템마다 달라진다. + */ + @Column(name = "set_name_ko", length = 64) + private String setNameKo; + + /** 같이 걸 수 없는 아이템의 {@code item_key} 목록. 콤마 구분. 지금은 전부 비어 있다. */ + @Column(name = "conflicts_with", length = 512) + private String conflictsWith; + + @Column(name = "active", nullable = false) + private boolean active; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 보유 여부. 저장하지 않고 매번 계산한다. + * + *

보유를 테이블로 두면 레벨이 오를 때마다 지급 배치가 필요하고, 그 배치가 한 번 밀리면 + * 사용자는 레벨은 올랐는데 아이템이 안 열린 상태로 남는다. 계산으로 두면 그 상태 자체가 없다. + * 테마 해금이 같은 방식을 쓴다. + * + *

비교 대상은 {@code requiredAbility} 가 정한다. 적혀 있으면 그 능력치 레벨, 비어 있으면 + * 총 학습 레벨이다. 갈림은 {@link CosmeticUnlockLevels#levelFor} 한 곳에만 있다. + * + *

경계는 포함이다. {@code required_level == 비교 대상 레벨} 이면 열린 것이다. + * 문제 복습 4 짜리 비니는 문제 복습이 4 가 되는 순간 써야지 5 가 돼서 열리면 안 된다. + */ + public boolean isOwnedBy(CosmeticUnlockLevels levels) { + return requiredLevel != null && requiredLevel <= levels.levelFor(requiredAbility); + } + + /** + * 충돌 목록을 잘라서 돌려준다. + * + *

비어 있는 조각은 버린다. 운영에서 콤마를 하나 더 찍는 실수(`a,,b`, `a,`)로 + * 빈 문자열이 아이템 키로 취급되면 엉뚱한 슬롯이 해제될 수 있다. + */ + public List conflictKeys() { + if (conflictsWith == null || conflictsWith.isBlank()) { + return List.of(); + } + return Arrays.stream(conflictsWith.split(",")) + .map(String::trim) + .filter(key -> !key.isEmpty()) + .toList(); + } + + public boolean isEquippable() { + return slot != null && slot.isEquippable(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java new file mode 100644 index 00000000..1cca9574 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -0,0 +1,84 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; + +/** + * 꾸미기 슬롯과 그리는 순서. + * + *

{@code layerOrder} 는 작을수록 뒤에 깔린다. 프론트가 이 순서를 코드에 박지 않도록 + * {@code GET /api/cosmetics} 응답에 목록으로 실어 보낸다. 나중에 슬롯이 늘거나 순서가 바뀌어도 + * 서버만 고치면 되고, 이미 깔린 앱도 새 순서를 따른다. + * + *

값 사이를 100 씩 띄운 것은 의도된 것이다. 나중에 두 슬롯 사이에 새 슬롯이 필요해지면 + * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. {@link #BAG}(450) 이 실제로 그렇게 들어왔다. + * + *

자리의 {@code layerOrder} 는 기본값이다. 아이템이 {@code cosmetic_item.layer_order} 로 + * 덮어쓸 수 있다. {@link #BAG} 이 그래서 하나로 합쳐질 수 있었다. 등에 메는 것은 본체(300)보다 뒤에 + * 깔려야 하고 앞으로 메는 것은 옷(400) 위에 올라가야 하는데, 층이 다르다는 이유만으로 자리를 + * 둘로 나누면 사용자에게 설명할 수 없는 탭이 하나 더 생긴다. + * + *

{@link #BASE} 는 장착 슬롯이 아니라 개구리 본체다. 본체 이미지도 {@code cosmetic_item} 행으로 + * 두기 때문에 슬롯 이름이 필요해서 여기 있을 뿐이고, 장착 가능한 슬롯 목록과 아이템 목록에서는 빠진다. + * 그래서 {@code equippable} 로 갈라 둔다. + * + *

{@code composited} 는 {@code equippable} 과 다른 축이다. 걸 수는 있지만 개구리 그림에는 + * 겹치지 않는 자리가 있다({@link #FRAME}). {@code layerOrder} 만 내려보내면 프론트는 순서대로 겹쳐 + * 그리는 수밖에 없어서, 프레임이 개구리 위에 덮인다. 어느 자리가 합성 대상인지도 데이터로 내려보낸다. + * 이 값이 없으면 프론트가 "FRAME 은 예외" 를 코드에 박아야 하는데, 그건 {@code layerOrder} 를 + * 응답에 실어 보내기로 한 이유를 그대로 무르는 것이다. + */ +@Getter +@RequiredArgsConstructor +public enum CosmeticSlot { + + BACKGROUND(100, "배경", true, true), + /** 개구리 본체. 장착 대상이 아니다. */ + BASE(300, "개구리 본체", false, true), + OUTFIT(400, "옷", true, true), + /** + * 가방. 등에 메는 것과 앞으로 메는 것이 한 자리다. + * + *

둘을 자리로 갈라 뒀었는데(BACK 200 / BAG 450) 각각 2개·3개뿐이라 탭을 나눌 만큼이 + * 아니었고, 사용자에게 "왜 가방 자리가 둘이지" 를 설명해야 했다. + * + *

대신 그리는 층을 아이템이 덮어쓴다. 자리 기본값은 450(옷 위)이고, + * 등에 메는 {@code back_backpack_*} 둘만 {@code cosmetic_item.layer_order} 에 200 을 직접 + * 갖고 있어 개구리 본체(300)보다 뒤에 깔린다. 자리 하나에 층이 둘인 것은 여기뿐이다. + */ + BAG(450, "가방", true, true), + NECK(500, "목", true, true), + FACE(600, "얼굴", true, true), + HEAD(700, "머리", true, true), + HAND(800, "손", true, true), + BADGE(850, "뱃지", true, true), + EFFECT(900, "효과", true, true), + /** + * 원형 프로필 사진의 테두리. 개구리에 겹치지 않는다. + * + *

걸 수 있는 자리라 옷장 목록에는 나가지만 개구리 합성에서는 빠지고 프로필 위젯이 따로 쓴다. + * {@code layerOrder} 1000 은 옷장에서의 자리 순서를 정하려고 둔 값이지 개구리 위에 그린다는 뜻이 아니다. + * 그 구분은 {@code composited = false} 가 한다. + * + *

에셋도 혼자 다르다. 나머지는 {@code assets/Cosmetic/{item_key}.png} 인데 + * 프레임만 {@code assets/ProfileFrame/{item_key}.svg} 다. + */ + FRAME(1000, "프레임", true, false); + + private final int layerOrder; + private final String nameKo; + private final boolean equippable; + /** 개구리 그림에 겹쳐 그리는 자리인지. {@link #FRAME} 만 false 다. */ + private final boolean composited; + + /** 사용자가 실제로 걸 수 있는 슬롯만. 그리는 순서대로 준다. */ + public static List equippableSlots() { + return Arrays.stream(values()) + .filter(CosmeticSlot::isEquippable) + .sorted(java.util.Comparator.comparingInt(CosmeticSlot::getLayerOrder)) + .toList(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java new file mode 100644 index 00000000..9e49bb3f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java @@ -0,0 +1,66 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; + +import java.util.EnumMap; +import java.util.Map; + +/** + * 해금 판정에 쓰는 사용자의 레벨 묶음. + * + *

보유 여부가 아이템마다 다른 레벨을 보게 되면서 생겼다. 아이템에 {@code required_ability} 가 + * 적혀 있으면 그 능력치 레벨을, 비어 있으면 총 학습 레벨을 본다. 판정을 하는 곳마다 + * "이 아이템은 어느 레벨과 비교해야 하지" 를 다시 따지면 한 군데만 빠뜨려도 + * 열려야 할 것이 안 열리거나 그 반대가 된다. 그 갈림을 {@link #levelFor(AbilityType)} 한 곳에 모은다. + * + *

레벨 값을 스냅샷으로 들고 다닌다. 조회 한 번 안에서 같은 사용자의 레벨이 달라 보이면 + * 목록의 {@code owned} 와 프리셋 계산이 서로 어긋날 수 있다. + */ +public record CosmeticUnlockLevels(long totalStudyLevel, Map abilityLevels) { + + /** 미션 상태가 아직 없는 사용자의 레벨. {@code StudyRoomMapper} 와 같은 기준이다. */ + public static final long DEFAULT_LEVEL = 1L; + + public CosmeticUnlockLevels { + abilityLevels = abilityLevels == null + ? Map.of() + : Map.copyOf(abilityLevels); + } + + /** 미션 상태가 없는 사용자. 모든 레벨이 1 이라 아무것도 열려 있지 않다. */ + public static CosmeticUnlockLevels defaults() { + return new CosmeticUnlockLevels(DEFAULT_LEVEL, Map.of()); + } + + public static CosmeticUnlockLevels from(UserMissionStatus status) { + if (status == null) { + return defaults(); + } + + Map levels = new EnumMap<>(AbilityType.class); + levels.put(AbilityType.ATTENDANCE, orDefault(status.getAttendanceLevel())); + levels.put(AbilityType.NOTE_WRITE, orDefault(status.getNoteWriteLevel())); + levels.put(AbilityType.PROBLEM_PRACTICE, orDefault(status.getProblemPracticeLevel())); + levels.put(AbilityType.NOTE_PRACTICE, orDefault(status.getNotePracticeLevel())); + + return new CosmeticUnlockLevels(orDefault(status.getTotalStudyLevel()), levels); + } + + /** + * 이 아이템과 비교할 레벨. + * + *

{@code ability} 가 null 이면 총 학습 레벨이다. 0 이나 -1 같은 마법값을 쓰지 않는 것과 + * 같은 이유로, "능력치 조건이 없다" 는 것을 null 하나로만 표현한다. + */ + public long levelFor(AbilityType ability) { + if (ability == null) { + return totalStudyLevel; + } + return abilityLevels.getOrDefault(ability, DEFAULT_LEVEL); + } + + private static long orDefault(Long level) { + return level == null ? DEFAULT_LEVEL : level; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java new file mode 100644 index 00000000..9d150213 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java @@ -0,0 +1,79 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자가 슬롯별로 무엇을 걸고 있는지. (userId, slot) 하나당 한 행이다. + * + *

(user_id, slot) 복합 기본키가 이 설계의 핵심이다. 한 슬롯에 두 개가 들어가는 것을 + * 애플리케이션 검사가 아니라 DB 가 막는다. 같은 사용자가 같은 슬롯에 두 아이템을 동시에 걸어도 + * 중복 행이 생길 수 없다. 그래서 장착을 + * {@code INSERT ... ON DUPLICATE KEY UPDATE} 한 문장으로 처리할 수 있고, 읽고 나서 쓰는 + * check-then-act 도, 유니크 충돌 예외를 잡아 UPDATE 로 넘어가는 경로도 필요 없다. + * + *

이 엔티티는 읽기 전용으로만 쓴다. 쓰기는 전부 리포지토리의 네이티브 upsert/삭제를 탄다. + * 엔티티를 고쳐 더티 체킹에 맡기면 위 보장이 사라진다. + * + *

user 와 cosmetic_item 에 연관관계를 걸지 않고 식별자만 들고 있다. mission_progress 와 같은 + * 이유로, 외래키를 걸면 INSERT 마다 부모 사용자 행에 공유 잠금이 붙어 이미 사용자 행을 배타 잠금으로 + * 잡는 미션 보상 지급 경로와 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@IdClass(UserCosmeticLoadoutId.class) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_cosmetic_loadout") +public class UserCosmeticLoadout { + + /** + * "이 슬롯을 일부러 비웠다"는 표시. + * + *

해제를 행 삭제로 처리하면 모든 슬롯을 벗은 사용자가 "한 번도 안 건드린 사용자"와 + * 구별되지 않는다. 그러면 다음 조회에서 기본 프리셋이 되살아나 벗은 것이 되돌아온다. + * 맨 개구리를 보고 싶어서 하나씩 벗은 사용자가 앱을 껐다 켜면 다시 옷을 입고 있는 셈이다. + * + *

그래서 해제는 삭제가 아니라 이 값으로 덮어쓴다. 조회할 때 이 값이 든 행은 장착 맵에서 빠진다. + * 언더스코어로 시작해 실제 아이템 키({@code hat_beanie} 같은 소문자 단어)와 겹칠 수 없고, + * 장착 요청은 {@code cosmetic_item} 에 있는 키만 받으므로 클라이언트가 이 값을 넣을 수도 없다. + */ + public static final String NONE = "__none__"; + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * V34 의 {@code slot VARCHAR(32)} 과 맞춘다. {@code columnDefinition} 이 없으면 Hibernate 가 + * MySQL 에서 네이티브 {@code ENUM(...)} 으로 만들어, 운영에는 들어갈 수 있는 값(옛 배포가 남긴 + * 슬롯 이름)이 테스트 스키마에는 아예 못 들어간다. 그 차이를 두면 자리를 없애는 변경의 + * 마이그레이션을 테스트로 재현할 수 없다. + */ + @Id + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 32, columnDefinition = "varchar(32)") + private CosmeticSlot slot; + + @Column(name = "item_key", nullable = false, length = 64) + private String itemKey; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** 일부러 비워 둔 슬롯인지. 장착 맵을 만들 때 걸러낸다. */ + public boolean isEmptySlot() { + return NONE.equals(itemKey); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java new file mode 100644 index 00000000..3859e388 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * {@link UserCosmeticLoadout} 의 복합 기본키 (user_id, slot). + * + *

JPA 는 복합키를 별도 클래스로 요구하고, 그 클래스는 직렬화 가능하며 equals/hashCode 가 + * 값 기준이어야 한다. 둘 중 하나라도 빠지면 같은 키를 두 번 조회할 때 영속성 컨텍스트가 + * 다른 행으로 착각한다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class UserCosmeticLoadoutId implements Serializable { + + private Long userId; + + private CosmeticSlot slot; +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java b/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java new file mode 100644 index 00000000..f7c86b4f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java @@ -0,0 +1,29 @@ +package com.aisip.OnO.backend.cosmetic.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 꾸미기 도메인 에러. 15000 대를 쓴다. 앞의 14000 대까지는 이미 다른 도메인이 쓰고 있다. + */ +@Getter +@RequiredArgsConstructor +public enum CosmeticErrorCase implements ErrorCase { + + USER_NOT_FOUND(404, 15001, "해당하는 유저가 존재하지 않습니다."), + + /** 없는 키이거나 비활성 아이템. 어느 쪽인지 구분해 주지 않는다. 2차 콘텐츠 목록이 새어 나간다. */ + COSMETIC_ITEM_NOT_FOUND(404, 15002, "존재하지 않는 꾸미기 아이템입니다."), + + COSMETIC_ITEM_NOT_OWNED(400, 15003, "아직 잠겨 있는 꾸미기 아이템입니다."), + + /** 요청한 슬롯과 아이템이 실제로 속한 슬롯이 다른 경우. */ + COSMETIC_SLOT_MISMATCH(400, 15004, "아이템을 걸 수 없는 슬롯입니다."), + + COSMETIC_SET_NOT_FOUND(404, 15005, "존재하지 않는 꾸미기 세트입니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java new file mode 100644 index 00000000..70d8d5de --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java @@ -0,0 +1,67 @@ +package com.aisip.OnO.backend.cosmetic.repository; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CosmeticItemRepository extends JpaRepository { + + /** + * 화면에 내려갈 아이템 전부. + * + *

{@code BASE}(개구리 본체)까지 함께 온다. 슬롯이 장착 대상인지는 호출부가 가른다. + * 여기서 미리 걸러 두 번 조회하면 목록 한 번에 끝날 것을 두 번 읽게 된다. + */ + List findAllByActiveTrue(); + + Optional findByItemKey(String itemKey); + + /** 세트 장착용. 비활성 아이템은 세트에 끼워 주지 않는다. */ + List findAllBySetIdAndActiveTrueOrderByIdAsc(String setId); + + /** + * 총 학습 레벨이 올라 새로 열린 아이템. + * + *

구간은 {@code (levelBefore, levelAfter]} 다. 앞은 열림, 뒤는 닫힘. + * 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. + * {@code required_level} 이 null 인 아이템은 레벨로 열리지 않으므로 비교에서 저절로 빠진다. + * + *

{@code required_ability} 가 있는 아이템은 여기서 빠진다. 그쪽은 총 학습 레벨이 아니라 + * 자기 능력치 레벨을 보기 때문에, 함께 잡으면 오르지도 않은 능력치의 아이템이 + * "방금 열렸다" 고 나간다. + */ + @Query(""" + SELECT i FROM CosmeticItem i + WHERE i.active = true + AND i.requiredAbility IS NULL + AND i.requiredLevel > :levelBefore + AND i.requiredLevel <= :levelAfter + ORDER BY i.requiredLevel ASC, i.itemKey ASC + """) + List findUnlockedByTotalLevelBetween( + @Param("levelBefore") long levelBefore, + @Param("levelAfter") long levelAfter + ); + + /** + * 한 능력치의 레벨이 올라 새로 열린 아이템. 구간 규칙은 위와 같다. + */ + @Query(""" + SELECT i FROM CosmeticItem i + WHERE i.active = true + AND i.requiredAbility = :ability + AND i.requiredLevel > :levelBefore + AND i.requiredLevel <= :levelAfter + ORDER BY i.requiredLevel ASC, i.itemKey ASC + """) + List findUnlockedByAbilityBetween( + @Param("ability") AbilityType ability, + @Param("levelBefore") long levelBefore, + @Param("levelAfter") long levelAfter + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java new file mode 100644 index 00000000..10661d6c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java @@ -0,0 +1,68 @@ +package com.aisip.OnO.backend.cosmetic.repository; + +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadoutId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface UserCosmeticLoadoutRepository extends JpaRepository { + + /** 소유권: 장착 상태는 언제나 요청한 사용자 것만 읽는다. */ + List findAllByUserId(Long userId); + + long countByUserId(Long userId); + + /** + * 슬롯 하나를 한 문장으로 걸거나 바꾼다. + * + *

"없으면 넣고 있으면 바꾼다"를 애플리케이션에서 갈라 쓰면 같은 사용자가 같은 슬롯에 + * 동시에 장착할 때 둘 다 "없다"를 읽고 INSERT 해 기본키 충돌이 난다. JPA 에서 제약 위반은 + * 트랜잭션을 rollback-only 로 만들기 때문에 잡아서 UPDATE 로 넘어갈 수도 없다. + * + *

{@code INSERT ... ON DUPLICATE KEY UPDATE} 는 중복 키를 만나면 MySQL 이 그 행에 + * 배타 잠금을 걸고 UPDATE 로 바꿔 실행한다. 예외 경로도 없고 잠금 승격으로 인한 교착도 없다. + * 뒤에 온 요청이 이기고, 어느 쪽이 이기든 행은 하나다. + * + *

해제도 이 메서드를 쓴다. {@code itemKey} 에 {@code UserCosmeticLoadout.NONE} 을 넣으면 + * "일부러 비운 슬롯" 행이 된다. 삭제하지 않는 이유는 엔티티 주석에 있다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_cosmetic_loadout (user_id, slot, item_key, updated_at) + VALUES (:userId, :slot, :itemKey, NOW(6)) + ON DUPLICATE KEY UPDATE + item_key = VALUES(item_key), + updated_at = NOW(6) + """, nativeQuery = true) + int equip( + @Param("userId") Long userId, + @Param("slot") String slot, + @Param("itemKey") String itemKey + ); + + /** + * 행이 없을 때만 넣는다. 이미 있으면 아무것도 하지 않는다. + * + *

기본 프리셋을 실제 행으로 굳힐 때 쓴다. {@code ON DUPLICATE KEY UPDATE user_id = user_id} + * 는 "이미 있으면 그대로 둔다"는 뜻이다. 사용자가 방금 고른 값을 프리셋이 덮어쓰면 안 되고, + * 같은 요청이 동시에 두 번 들어와도 결과가 같아야 한다. + * + *

{@code INSERT IGNORE} 를 쓰지 않는다. 그쪽은 키 충돌만이 아니라 길이 초과·타입 불일치 같은 + * 진짜 오류까지 경고로 삼켜 버린다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_cosmetic_loadout (user_id, slot, item_key, updated_at) + VALUES (:userId, :slot, :itemKey, NOW(6)) + ON DUPLICATE KEY UPDATE user_id = user_id + """, nativeQuery = true) + int insertIfAbsent( + @Param("userId") Long userId, + @Param("slot") String slot, + @Param("itemKey") String itemKey + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java new file mode 100644 index 00000000..b58c20ed --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -0,0 +1,583 @@ +package com.aisip.OnO.backend.cosmetic.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticItemResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticListResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticSlotDto; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticUnlockLevels; +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; +import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; +import com.aisip.OnO.backend.cosmetic.repository.CosmeticItemRepository; +import com.aisip.OnO.backend.cosmetic.repository.UserCosmeticLoadoutRepository; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * 꾸미기 조회와 장착. + * + *

보유 여부는 저장하지 않는다. {@code required_level <= 비교 대상 레벨} 로 매번 계산한다. + * 비교 대상은 아이템의 {@code required_ability} 가 정한다. 적혀 있으면 그 능력치 레벨, + * 비어 있으면 총 학습 레벨이다. + * 보유를 테이블로 두면 레벨이 오를 때마다 지급이 필요하고, 그 지급이 한 번 밀리면 사용자는 + * 레벨은 올랐는데 아이템은 안 열린 상태로 남는다. 계산으로 두면 그런 상태가 아예 없다. + * 테마 해금이 같은 방식을 쓴다. + * + *

쓰기는 전부 {@code UserCosmeticLoadoutRepository} 의 네이티브 upsert 를 탄다. + * {@code (user_id, slot)} 기본키가 한 슬롯에 하나만 들어가는 것을 DB 수준에서 보장하는데, + * 엔티티를 읽어 고치는 방식으로 쓰면 그 보장이 무의미해진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CosmeticService { + + /** + * 개구리 본체를 가리키는 아이템 키. 슬롯이 {@link CosmeticSlot#BASE} 라 장착 대상이 아니다. + * + *

본체 이미지까지 데이터로 두는 이유는 아이템 이미지와 같다. S3 로 옮길 때 + * {@code image_url} 만 바꾸면 앱 배포 없이 전환돼야 하는데, 본체만 코드 상수면 그 한 장 때문에 + * 배포를 해야 한다. + */ + private static final String BASE_ITEM_KEY = "BASE"; + + /** + * 본체 행이 없을 때 쓸 값. 시드가 항상 넣어 주므로 실제로는 쓰이지 않는다. + * + *

없다고 500 을 내면, 본체 행 하나가 빠졌을 뿐인데 꾸미기 화면 전체가 열리지 않는다. + */ + private static final String FALLBACK_BASE_IMAGE_URL = "assets/Cosmetic/BASE.png"; + + private final CosmeticItemRepository cosmeticItemRepository; + private final UserCosmeticLoadoutRepository userCosmeticLoadoutRepository; + private final UserRepository userRepository; + + /** + * 아이템 목록과 현재 장착 상태. + * + *

잠긴 아이템도 {@code owned: false} 로 함께 내려간다. 화면에 "레벨 6 에 열려요" 를 보여주려면 + * 잠긴 것이 무엇인지도 알아야 한다. + * + *

읽기 전용이다. 여기서 장착 행을 만들지 않는다. 목록만 열어 본 사용자 수만큼 빈 행이 생기는 것을 피한다. + */ + public CosmeticListResponseDto getCosmetics(Long userId) { + CosmeticUnlockLevels levels = levelsOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + + List items = equippableItems(activeItems).stream() + .map(item -> CosmeticItemResponseDto.of(item, levels)) + .toList(); + + return new CosmeticListResponseDto( + baseImageUrl(activeItems), + CosmeticSlot.BASE.getLayerOrder(), + CosmeticSlot.equippableSlots().stream().map(CosmeticSlotDto::from).toList(), + items, + currentEquipped(userId, activeItems, levels) + ); + } + + /** + * 슬롯 하나 장착. {@code itemKey} 가 null 이면 해제한다. + * + *

충돌하는 다른 슬롯의 아이템은 자동으로 벗기고, 벗긴 슬롯을 응답에 담는다. + * 충돌을 사용자에게 떠넘기면 "왜 안 걸리지" 하는 상태가 되고, 조용히 벗기기만 하면 + * 사라진 이유를 알 수 없다. 벗기고 알려 준다. + */ + @Transactional + public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String itemKey) { + CosmeticUnlockLevels levels = levelsOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + CosmeticItem item = resolveEquipTarget(slot, itemKey, itemsByKey, levels); + + materializePresetIfUntouched(userId, activeItems, levels); + + // 쓸 내용을 먼저 다 모은 뒤 슬롯 이름 순서로 적용한다. + // + // 한 요청이 여러 행을 건드릴 수 있는데(장착 1 + 충돌 해제 N), 두 요청이 서로 다른 순서로 + // 같은 행들을 잠그면 교착이 난다. 모든 트랜잭션이 같은 순서로 잠그면 교착 자체가 성립하지 않는다. + // 슬롯 이름은 기본키의 두 번째 컬럼이라 이 순서가 곧 인덱스 순서다. + Map writes = orderedWrites(); + writes.put(slot, item == null ? UserCosmeticLoadout.NONE : item.getItemKey()); + + List unequipped = conflictingSlots(userId, item, itemsByKey); + unequipped.forEach(conflicting -> writes.put(conflicting, UserCosmeticLoadout.NONE)); + + applyWrites(userId, writes); + + log.info("userId: {} equipped slot: {}, itemKey: {}, unequipped: {}", + userId, slot, itemKey, unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); + } + + /** + * 차림 전체를 한 번에 바꾼다. 꾸미기 화면의 저장 버튼이 이 경로다. + * + *

전체 교체다. 요청에 없는 슬롯은 비운다. 저장 버튼은 "지금 이 차림이 내 차림이다" 라는 + * 선언이라 서버도 그대로 받는다. "안 보낸 것은 그대로 둔다" 로 하면 시착 화면에서 벗어 놓고 + * 저장한 것을 표현할 방법이 없어진다. + * + *

한 건이라도 검증에 걸리면 전부 거절한다. 프론트가 슬롯 수만큼 {@link #equip} 을 부르면 + * 중간에 끊겼을 때 모자만 바뀌고 옷은 안 바뀐 반쪽 차림이 남는데, 사용자는 저장을 한 번 눌렀을 뿐이라 + * 다시 들어왔을 때 무엇이 저장된 것인지 알 수 없다. 그 상태를 아예 만들지 않으려고 이 API 가 있다. + * + *

{@link #equip} 과 {@link #equipSet} 은 그대로 둔다. 옷장 격자에서 한 칸만 눌러 바로 거는 + * 경로가 따로 있다. + */ + @Transactional + public CosmeticEquipResponseDto equipAll(Long userId, Map requested) { + CosmeticUnlockLevels levels = levelsOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + // 검사를 먼저 전부 끝낸다. equipSet 과 같은 이유로, 쓰다가 중간에 거절하면 앞의 몇 개만 걸린 + // 채로 롤백을 믿어야 한다. 판정은 equip 이 쓰는 것과 같은 규칙을 그대로 탄다. + // 없는 키·비활성·잠김·슬롯 어긋남이 여기서 걸리면 아래 쓰기로 내려가지 않는다. + List targets = new ArrayList<>(); + for (Map.Entry entry : requested.entrySet()) { + CosmeticItem item = resolveEquipTarget(entry.getKey(), entry.getValue(), itemsByKey, levels); + // null 은 "그 자리를 비운다" 다. 키를 아예 빼고 보낸 것과 같게 다룬다. + if (item != null) { + targets.add(item); + } + } + + List unequipped = new ArrayList<>(); + List kept = resolveInRequestConflicts(targets, unequipped); + + // 전체 교체라 걸 수 있는 자리를 하나도 빠짐없이 쓴다. 요청에 없던 자리를 NONE 으로 덮어야 + // "지금 이 차림이 전부" 가 된다. + // + // 여기에는 materializePresetIfUntouched 가 없다. 프리셋을 굳히는 이유는 첫 변경 때 나머지 + // 자리가 통째로 사라지는 것을 막으려는 것인데, 전체 교체는 그 나머지 자리까지 사용자가 정한 + // 값으로 직접 채운다. 프리셋을 먼저 깔면 방금 벗어 놓고 저장한 것을 되살렸다가 다시 벗는 셈이다. + // 이 요청 하나로 모든 자리에 행이 생기므로 다음 조회에서 프리셋이 되살아날 여지도 없다. + Map writes = orderedWrites(); + CosmeticSlot.equippableSlots().forEach(slot -> writes.put(slot, UserCosmeticLoadout.NONE)); + kept.forEach(item -> writes.put(item.getSlot(), item.getItemKey())); + + applyWrites(userId, writes); + + log.info("userId: {} equipped all: {}, unequipped: {}", + userId, kept.stream().map(CosmeticItem::getItemKey).toList(), unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); + } + + /** + * 한 요청 안에서 서로 충돌하는 둘이 함께 들어왔을 때 어느 쪽을 남길지 가른다. + * + *

프론트도 {@code conflictsWith} 를 보지만 같이 걸 수 없는 조합이 그대로 담겨 들어올 수 있다. + * 받은 차림을 그대로 저장하지 않고 서버가 마지막으로 한 번 더 본다. + * + *

뒤에 깔리는 자리(layerOrder 가 작은 쪽)를 남기고 앞의 것을 벗긴다. 맵에 담긴 순서나 + * JSON 키 순서에 맡기면 같은 본문을 두 번 보냈을 때 다른 것이 걸릴 수 있고, 사용자는 조회할 때마다 + * 걸려 있는 것이 달라 보인다. + * + *

층 순서를 기준으로 삼은 이유는 충돌이 잡히는 조합이 대개 "옷과 그 위에 얹히는 것" 이기 + * 때문이다. 뒤를 남기면 옷 입은 개구리에서 모자 하나가 빠지고, 앞을 남기면 모자만 쓴 벗은 개구리가 + * 된다. 잃는 것이 작은 쪽을 고른다. + * + *

지금은 자리마다 층이 겹치지 않지만 자리 이름으로 한 번 더 묶는다. 나중에 같은 층의 자리가 + * 생겨도 결과가 흔들리지 않는다. + * + *

여기서 보는 것은 요청 안의 충돌뿐이다. 지금 걸려 있는 것과의 충돌은 볼 필요가 없다. + * 전체 교체라 걸려 있던 것은 모두 이 요청의 결과로 덮이기 때문이다. + * + * @param unequipped 벗겨진 자리를 담아 갈 목록. 프론트가 "가디건이 벗겨졌어요" 를 띄우는 데 쓴다. + */ + private List resolveInRequestConflicts(List targets, + List unequipped) { + List ordered = targets.stream() + .sorted(Comparator + .comparingInt((CosmeticItem item) -> item.getSlot().getLayerOrder()) + .thenComparing(item -> item.getSlot().name())) + .toList(); + + List kept = new ArrayList<>(); + for (CosmeticItem candidate : ordered) { + if (kept.stream().anyMatch(accepted -> conflictsEachOther(accepted, candidate))) { + unequipped.add(candidate.getSlot()); + continue; + } + kept.add(candidate); + } + + // 응답에 실리는 순서를 자리 이름으로 고정한다. conflictingSlots 가 쓰는 순서와 같다. + unequipped.sort(Comparator.comparing(Enum::name)); + return kept; + } + + /** + * 세트 한 번에 장착. + * + *

하나라도 보유하지 않았으면 통째로 거절한다. 되는 것만 걸어 주면 사용자는 "졸업 세트를 걸었는데 + * 학사모만 있는" 어중간한 상태를 보게 되고, 그게 의도인지 버그인지 구별할 수 없다. + */ + @Transactional + public CosmeticEquipResponseDto equipSet(Long userId, String setId) { + CosmeticUnlockLevels levels = levelsOf(userId); + List setItems = cosmeticItemRepository.findAllBySetIdAndActiveTrueOrderByIdAsc(setId).stream() + .filter(CosmeticItem::isEquippable) + .toList(); + + if (setItems.isEmpty()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SET_NOT_FOUND); + } + // 검사를 먼저 전부 끝낸다. 걸다가 중간에 거절하면 앞의 몇 개만 걸린 채로 롤백을 믿어야 한다. + boolean anyLocked = setItems.stream().anyMatch(item -> !item.isOwnedBy(levels)); + if (anyLocked) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + } + + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + materializePresetIfUntouched(userId, activeItems, levels); + + Map writes = orderedWrites(); + setItems.forEach(item -> writes.put(item.getSlot(), item.getItemKey())); + + // 세트 안에서 슬롯이 겹치면 나중 것이 이긴다. 애초에 겹치게 시드하지 않지만, + // 겹쳤을 때 행이 두 개 생기지 않는다는 것 자체는 기본키가 보장한다. + List unequipped = new ArrayList<>(); + for (CosmeticItem item : setItems) { + for (CosmeticSlot conflicting : conflictingSlots(userId, item, itemsByKey)) { + // 세트가 채울 슬롯은 어차피 덮어쓰므로 "벗겼다" 고 알리지 않는다. + if (!writes.containsKey(conflicting) && !unequipped.contains(conflicting)) { + unequipped.add(conflicting); + } + } + } + unequipped.forEach(conflicting -> writes.put(conflicting, UserCosmeticLoadout.NONE)); + + applyWrites(userId, writes); + + log.info("userId: {} equipped set: {}, items: {}, unequipped: {}", + userId, setId, setItems.stream().map(CosmeticItem::getItemKey).toList(), unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); + } + + /** + * 이번 레벨업으로 새로 열린 아이템. 미션 보상 수령 응답이 쓴다. + * + *

구간은 {@code (levelBefore, levelAfter]} 라, 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. + * 레벨이 그대로면 빈 목록이다. + * + *

총 학습 레벨만 본다. {@code required_ability} 가 있는 아이템은 여기 걸리지 않는다. + */ + public List findUnlockedBetween(long levelBefore, long levelAfter) { + return toUnlockedDtos(unlockedByTotalLevel(levelBefore, levelAfter)); + } + + /** + * 이번 지급으로 새로 열린 아이템 전부. 총 학습 레벨과 능력치 레벨을 함께 본다. + * + *

미션 보상은 언제나 한 능력치에만 들어간다. 그 능력치 레벨이 오르면 그쪽 구간에서, + * 총 학습 레벨이 오르면 총 학습 구간에서 열린 것이 나온다. 둘 다 오를 수도 있어 합쳐서 준다. + * + *

총 학습 레벨만 보던 때에는 능력치 레벨이 올라 열린 아이템이 수령 응답에서 통째로 빠졌다. + * 해금 자체는 계산이라 다음 조회에서 드러나지만, 사용자는 그 순간에 아무 일도 없었다고 본다. + * + *

{@code ability} 가 null 이면(XP 가 아닌 보상 등) 총 학습 구간만 본다. + */ + public List findUnlockedBetween(long totalLevelBefore, long totalLevelAfter, + AbilityType ability, + long abilityLevelBefore, long abilityLevelAfter) { + List unlocked = new ArrayList<>(unlockedByTotalLevel(totalLevelBefore, totalLevelAfter)); + + if (ability != null && abilityLevelAfter > abilityLevelBefore) { + unlocked.addAll(cosmeticItemRepository.findUnlockedByAbilityBetween( + ability, abilityLevelBefore, abilityLevelAfter)); + } + + // 두 목록을 이어 붙였으니 다시 정렬한다. 정렬 기준이 없으면 같은 레벨업인데도 + // 응답에 실리는 순서가 조회마다 달라 보인다. + unlocked.sort(Comparator + .comparingInt((CosmeticItem item) -> item.getRequiredLevel() == null + ? Integer.MAX_VALUE : item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)); + + return toUnlockedDtos(unlocked); + } + + private List unlockedByTotalLevel(long levelBefore, long levelAfter) { + if (levelAfter <= levelBefore) { + return List.of(); + } + return cosmeticItemRepository.findUnlockedByTotalLevelBetween(levelBefore, levelAfter); + } + + private List toUnlockedDtos(List items) { + return items.stream() + .filter(CosmeticItem::isEquippable) + .map(UnlockedCosmeticDto::from) + .toList(); + } + + // ─────────────────────────── 장착 대상 판정 ─────────────────────────── + + /** + * 요청이 가리키는 아이템. 해제 요청이면 null 을 준다. + * + *

없는 키와 비활성 아이템을 같은 에러로 답한다. 구분해 주면 키를 훑어서 아직 공개하지 않은 + * 2차 콘텐츠 목록을 알아낼 수 있다. + */ + private CosmeticItem resolveEquipTarget(CosmeticSlot slot, String itemKey, + Map itemsByKey, CosmeticUnlockLevels levels) { + if (!slot.isEquippable()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + if (itemKey == null || itemKey.isBlank()) { + return null; + } + + CosmeticItem item = itemsByKey.get(itemKey); + if (item == null || !item.isEquippable()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + } + if (item.getSlot() != slot) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + if (!item.isOwnedBy(levels)) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + } + return item; + } + + /** + * 새로 거는 아이템과 같이 쓸 수 없는, 지금 걸려 있는 슬롯들. + * + *

충돌은 양쪽 모두 본다. 후드 옷에 "모자와 충돌" 이라고만 적고 모자 쪽에는 안 적는 것이 + * 자연스러운데, 한쪽만 보면 모자를 나중에 거는 경우에 검사가 통째로 빠진다. + * 어느 쪽에 적어도 동작해야 데이터를 채우는 사람이 실수하지 않는다. + * + *

같은 슬롯은 대상이 아니다. 어차피 덮어써진다. + * + *

카탈로그에 없는(비활성으로 내려간) 아이템이 걸려 있으면 건드리지 않는다. 충돌 여부를 알 수 없는데 + * 벗기면 사용자 입장에서는 이유 없이 사라진 것이다. + */ + private List conflictingSlots(Long userId, CosmeticItem item, Map itemsByKey) { + if (item == null || (item.conflictKeys().isEmpty() && !anyoneConflictsWith(item, itemsByKey))) { + return List.of(); + } + + List conflicting = new ArrayList<>(); + for (UserCosmeticLoadout row : userCosmeticLoadoutRepository.findAllByUserId(userId)) { + if (row.isEmptySlot() || row.getSlot() == item.getSlot()) { + continue; + } + CosmeticItem equipped = itemsByKey.get(row.getItemKey()); + if (equipped == null) { + continue; + } + if (conflictsEachOther(item, equipped)) { + conflicting.add(row.getSlot()); + } + } + conflicting.sort(Comparator.comparing(Enum::name)); + return conflicting; + } + + /** + * 둘을 같이 걸 수 없는지. + * + *

충돌은 양쪽 모두 본다. 근거는 {@link #conflictingSlots} 에 있다. + * 한 슬롯씩 거는 경로와 차림 전체를 저장하는 경로가 같은 판정을 써야 해서 여기로 뽑았다. + * 둘로 나눠 적으면 한쪽만 고쳐질 때 같은 조합이 경로에 따라 다르게 걸린다. + */ + private boolean conflictsEachOther(CosmeticItem one, CosmeticItem other) { + return one.conflictKeys().contains(other.getItemKey()) + || other.conflictKeys().contains(one.getItemKey()); + } + + /** 이 아이템을 충돌 대상으로 지목한 아이템이 하나라도 있는지. 없으면 장착 행을 읽지 않고 끝낸다. */ + private boolean anyoneConflictsWith(CosmeticItem item, Map itemsByKey) { + return itemsByKey.values().stream() + .anyMatch(other -> other.conflictKeys().contains(item.getItemKey())); + } + + // ─────────────────────────── 장착 상태 ─────────────────────────── + + /** + * 지금 걸려 있는 것. 행이 하나도 없으면 기본 프리셋으로 답한다. + * + *

비활성으로 내려간 아이템과 레벨이 내려가 잠긴 아이템은 응답에서 뺀다. 행은 그대로 두고 + * 보여주기만 멈춘다. 프론트는 아이템 목록에 없는 키를 받으면 그릴 이미지가 없고, + * "잠긴 것을 걸고 있다" 는 상태도 화면에서 설명할 방법이 없다. + * 관리자가 레벨을 되돌리면 걸려 있던 것이 그대로 다시 보인다. + */ + private Map currentEquipped(Long userId, List activeItems, + CosmeticUnlockLevels levels) { + List rows = userCosmeticLoadoutRepository.findAllByUserId(userId); + if (rows.isEmpty()) { + return defaultPreset(activeItems, levels); + } + + Map itemsByKey = indexByKey(activeItems); + Map equipped = new EnumMap<>(CosmeticSlot.class); + for (UserCosmeticLoadout row : rows) { + if (row.isEmptySlot()) { + continue; + } + CosmeticItem item = itemsByKey.get(row.getItemKey()); + if (item == null || !item.isEquippable() || !item.isOwnedBy(levels)) { + continue; + } + // 카탈로그에서 아이템의 슬롯이 옮겨 가면 예전에 쓴 행은 엉뚱한 자리를 가리키게 된다. + // 그대로 내려보내면 프론트가 엉뚱한 자리에 그린다. 행은 두고 보여주기만 멈춘다. + if (item.getSlot() != row.getSlot()) { + continue; + } + equipped.put(row.getSlot(), row.getItemKey()); + } + return equipped; + } + + /** + * 장착 행이 없는 사용자에게 보여줄 기본 차림. + * + *

슬롯마다 열린 것 중 {@code required_level} 이 가장 높은 것을 고른다. 한 슬롯 안에 + * 능력치가 다른 아이템이 섞여 있어도(머리 슬롯에는 문제 복습 아이템과 총 학습 아이템이 함께 있다) + * 숫자만 비교한다. 어차피 프리셋은 "가장 늦게 열린 것을 보여준다"는 어림이고, + * 어느 쪽이 걸리든 사용자가 바꿀 수 있다. + * + *

이 프리셋이 있는 이유: 지금 레벨이 높은 사용자는 다 자란 개구리를 보고 있다. + * 꾸미기로 전환하면서 장착 데이터가 없다는 이유로 맨 개구리가 되면, 사용자 입장에서는 + * 아무것도 안 했는데 하향된 것이다. 프리셋이 그 순간을 막는다. + * 마이그레이션으로 전 사용자 행을 만드는 방법도 있지만, 수십만 행을 미리 쓰고 나면 + * 나중에 프리셋 규칙을 바꿀 수 없다. 계산으로 두면 규칙만 고치면 된다. + * + *

{@code composited = false} 인 자리는 채우지 않는다. 지금은 {@link CosmeticSlot#FRAME} + * 하나다. 위의 "아무것도 안 했는데 하향된 것" 이라는 근거가 프레임에는 성립하지 않는다. + * 프레임은 꾸미기 전에도 없었고, 개구리에 얹히는 것들과 달리 내 화면 밖에서 남들과 나란히 + * 보인다(스터디룸 멤버 목록). 그런데 멤버 응답에는 아직 치장이 실리지 않아, 프리셋이 내 프레임을 + * 자동으로 걸면 목록에서 나만 테두리가 있고 나머지는 맨 얼굴이 된다. + * 내가 고른 것도 아닌데 그렇게 보이는 것이라 사용자가 직접 고를 때만 걸리게 한다. + * + *

나중에 스터디룸 멤버 응답에 치장이 실리면 이 규칙은 다시 볼 값어치가 있다. + * 그때는 모두가 프레임을 갖고 있으니 "나만 튄다" 는 근거가 사라진다. + */ + private Map defaultPreset(List activeItems, CosmeticUnlockLevels levels) { + Map preset = new EnumMap<>(CosmeticSlot.class); + for (CosmeticSlot slot : presetSlots()) { + activeItems.stream() + .filter(item -> item.getSlot() == slot) + .filter(item -> item.isOwnedBy(levels)) + // 레벨이 높은 것이 나중에 얻은 것이다. 같은 레벨에 여러 개면 키 순서로 고정한다. + // 정렬 기준이 없으면 조회할 때마다 다른 것이 걸려 있는 것처럼 보인다. + // isOwnedBy 를 통과한 아이템은 requiredLevel 이 null 이 아니다. + .min(Comparator + .comparingInt((CosmeticItem item) -> -item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)) + .ifPresent(item -> preset.put(slot, item.getItemKey())); + } + return preset; + } + + /** + * 프리셋이 채우는 자리. 걸 수 있고 개구리에 겹치는 자리만이다. + * + *

{@code equippable} 과 {@code composited} 를 함께 보는 곳은 여기뿐이다. 장착 자체는 + * 프레임도 다른 자리와 똑같이 받는다. 자동으로 걸어 주지 않을 뿐이다. + */ + private static List presetSlots() { + return CosmeticSlot.equippableSlots().stream() + .filter(CosmeticSlot::isComposited) + .toList(); + } + + /** + * 한 번도 장착을 건드리지 않은 사용자의 프리셋을 실제 행으로 굳힌다. + * + *

이게 없으면 첫 장착이 재앙이 된다. 레벨이 높은 사용자가 모자만 바꾸는 순간 행이 하나 생기고, + * 그때부터 프리셋을 타지 않으므로 배경·옷·가방이 통째로 사라진다. 사용자는 모자를 바꿨을 뿐인데 + * 개구리가 벗겨진다. + * + *

그래서 첫 변경 때 지금 보고 있던 차림을 그대로 행으로 옮겨 적고, 그 위에 변경을 얹는다. + * 사용자가 보던 화면이 그대로 이어진다. + * + *

{@code insertIfAbsent} 라 이미 있는 행은 건드리지 않는다. 행이 하나라도 있으면 + * 이 메서드는 아무 일도 하지 않아야 하는데, 조건 검사와 쓰기 사이에 다른 요청이 끼어들어도 + * 그 성질이 유지된다. + */ + private void materializePresetIfUntouched(Long userId, List activeItems, + CosmeticUnlockLevels levels) { + if (userCosmeticLoadoutRepository.countByUserId(userId) > 0) { + return; + } + + Map preset = orderedWrites(); + preset.putAll(defaultPreset(activeItems, levels)); + preset.forEach((slot, itemKey) -> + userCosmeticLoadoutRepository.insertIfAbsent(userId, slot.name(), itemKey)); + } + + private void applyWrites(Long userId, Map writes) { + writes.forEach((slot, itemKey) -> userCosmeticLoadoutRepository.equip(userId, slot.name(), itemKey)); + } + + /** 슬롯 이름 순으로 정렬되는 빈 맵. 기본키 순서대로 쓰기 위한 것이다. */ + private Map orderedWrites() { + return new TreeMap<>(Comparator.comparing(Enum::name)); + } + + // ─────────────────────────── 공통 ─────────────────────────── + + private List equippableItems(List activeItems) { + return activeItems.stream() + .filter(CosmeticItem::isEquippable) + // 뒤에서 앞 순서로, 같은 슬롯 안에서는 먼저 열리는 것부터. + // 잠긴 것은 해금 레벨을 모르므로 맨 뒤에 둔다. + .sorted(Comparator + .comparingInt((CosmeticItem item) -> item.getSlot().getLayerOrder()) + .thenComparing(item -> item.getRequiredLevel() == null + ? Integer.MAX_VALUE : item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)) + .toList(); + } + + private String baseImageUrl(List activeItems) { + return activeItems.stream() + .filter(item -> BASE_ITEM_KEY.equals(item.getItemKey())) + .findFirst() + .map(CosmeticItem::getImageUrl) + .orElse(FALLBACK_BASE_IMAGE_URL); + } + + /** 같은 키가 두 번 나올 수 없다. uk_cosmetic_item_key 가 막는다. */ + private Map indexByKey(List items) { + Map byKey = new LinkedHashMap<>(); + items.forEach(item -> byKey.put(item.getItemKey(), item)); + return byKey; + } + + /** 해금 판정에 쓸 레벨 묶음. 한 요청 안에서는 이 스냅샷 하나만 본다. */ + private CosmeticUnlockLevels levelsOf(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(CosmeticErrorCase.USER_NOT_FOUND)); + + return CosmeticUnlockLevels.from(user.getUserMissionStatus()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/exception/FeedbackErrorCase.java b/src/main/java/com/aisip/OnO/backend/feedback/exception/FeedbackErrorCase.java new file mode 100644 index 00000000..c1056e32 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/feedback/exception/FeedbackErrorCase.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.feedback.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FeedbackErrorCase implements ErrorCase { + + FEEDBACK_NOT_FOUND(404, 13001, "피드백을 찾을 수 없습니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java index 63924edb..ff8bad1e 100644 --- a/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java +++ b/src/main/java/com/aisip/OnO/backend/feedback/service/FeedbackService.java @@ -1,8 +1,10 @@ package com.aisip.OnO.backend.feedback.service; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.feedback.dto.FeedbackRequestDto; import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto; import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import com.aisip.OnO.backend.feedback.exception.FeedbackErrorCase; import com.aisip.OnO.backend.feedback.repository.UserFeedbackRepository; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import lombok.RequiredArgsConstructor; @@ -23,6 +25,9 @@ @Transactional(readOnly = true) public class FeedbackService { + /** {@code UserFeedback.ipAddress} 컬럼 길이(varchar(50))와 맞춘다. */ + private static final int IP_ADDRESS_MAX_LENGTH = 50; + private final UserFeedbackRepository feedbackRepository; private final DiscordWebhookNotificationService discordWebhookNotificationService; @@ -48,7 +53,7 @@ public void save(FeedbackRequestDto dto, String ipAddress) { .mostUsedFeature(dto.getMostUsedFeature()) .painPoints(nullIfBlank(dto.getPainPoints())) .desiredFeatures(nullIfBlank(dto.getDesiredFeatures())) - .ipAddress(ipAddress) + .ipAddress(truncate(ipAddress, IP_ADDRESS_MAX_LENGTH)) .submittedAt(LocalDateTime.now()) .build(); @@ -56,16 +61,23 @@ public void save(FeedbackRequestDto dto, String ipAddress) { notifyDiscord(feedback); } + /** + * 관리자 화면의 페이지네이션 입력은 그대로 신뢰할 수 없다. + * page 가 음수이거나 size 가 0 이하이면 {@link PageRequest#of}가 IllegalArgumentException 을 던져 + * 관리자 화면이 통째로 500 이 됐다. 잘못된 파라미터는 유효한 범위로 보정한다. + */ public Page findAll(int page, int size) { return feedbackRepository - .findAllByOrderBySubmittedAtDesc(PageRequest.of(page, size)) + .findAllByOrderBySubmittedAtDesc(PageRequest.of(Math.max(page, 0), Math.max(size, 1))) .map(FeedbackResponseDto::from); } public FeedbackResponseDto findById(Long id) { return feedbackRepository.findById(id) .map(FeedbackResponseDto::from) - .orElseThrow(() -> new IllegalArgumentException("피드백을 찾을 수 없습니다: " + id)); + // IllegalArgumentException 은 GlobalExceptionHandler 의 마지막 Exception 핸들러로 떨어져 + // 500 + Discord 에러 알림이 됐다. 없는 리소스 조회는 404 다. + .orElseThrow(() -> new ApplicationException(FeedbackErrorCase.FEEDBACK_NOT_FOUND)); } public long count() { @@ -116,4 +128,16 @@ private String multiSelectWithOther(List items, String otherText) { private String nullIfBlank(String s) { return (s == null || s.isBlank()) ? null : s.trim(); } + + /** + * IP 는 사용자가 {@code X-Forwarded-For} 헤더로 얼마든지 길게 조작할 수 있는 값이다. + * {@code ip_address} 컬럼은 varchar(50) 이므로 그대로 넣으면 Data too long 으로 저장이 실패하고, + * 설문 응답 자체가 버려진다. 응답 내용과 달리 IP 는 부가 정보이므로 잘라서 담는다. + */ + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } } diff --git a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java index 65e03155..6aad0c53 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/folder/exception/FolderErrorCase.java @@ -16,7 +16,9 @@ public enum FolderErrorCase implements ErrorCase { ROOT_FOLDER_CANNOT_REMOVE(400, 5004, "루트 폴더는 삭제할 수 없습니다."), - ROOT_FOLDER_CANNOT_UPDATE(400, 5005, "루트 폴더는 수정할 수 없습니다."); + ROOT_FOLDER_CANNOT_UPDATE(400, 5005, "루트 폴더는 수정할 수 없습니다."), + + INVALID_PARENT_FOLDER(400, 5006, "폴더를 자기 자신이나 하위 폴더로 옮길 수 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index facee834..389c6fb8 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -1,7 +1,9 @@ package com.aisip.OnO.backend.folder.repository; import com.aisip.OnO.backend.folder.entity.Folder; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,10 +18,99 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByUserId(Long userId); + /** + * 문제를 폴더에 넣는 경로(등록, 이동)에서 폴더를 공유 잠금(FOR SHARE)으로 읽는다. (#233) + * + *

잠금 없이 읽으면 폴더 삭제가 아직 커밋되지 않은 폴더를 살아 있는 것으로 보고 문제를 넣어, + * 삭제된 폴더를 가리키는 고아 문제가 남았다. 공유 잠금은 삭제의 배타 잠금과만 충돌하므로 + * 같은 폴더로 들어오는 등록끼리는 서로 기다리지 않는다. 삭제가 먼저 잡았으면 커밋까지 기다린 뒤 + * 최신 행을 다시 읽고, 그때는 {@code deleted_at} 이 찍혀 있어 {@code @SQLRestriction} 에 걸러진다. + */ + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select f from Folder f where f.id = :folderId") + Optional findByIdForShare(@Param("folderId") Long folderId); + + /** {@link #findByIdForShare} 의 일괄 등록용. 잠금은 PK 순서로 잡힌다. */ + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select f from Folder f where f.id in :folderIds") + List findAllByIdInForShare(@Param("folderIds") Collection folderIds); + + /** + * 전체 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) + * + *

폴더를 골라 지우는 경로는 이걸 쓰지 않는다. 사용자 폴더 전체를 잡으면 삭제와 아무 상관 없는 + * 폴더로 들어오는 등록까지 전부 줄을 서고, 기다리는 요청이 커넥션을 하나씩 물고 있어서 + * 커넥션 풀이 바닥난다. 그러면 다른 사용자의 요청까지 커넥션을 못 받고 죽는다. (#319) + * 그쪽은 {@link #lockAllByIdIn} 과 {@link #lockAllByParentFolderIdIn} 으로 삭제 대상 서브트리만 잡는다. + * + *

여기는 어차피 사용자 폴더 전부가 삭제 대상이라 범위를 줄일 것이 없다. 계정 정리 때만 타는 드문 경로다. + * + *

반드시 트랜잭션의 첫 조회여야 한다. REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다. + * 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아 + * 문제 삭제에서 빠진다. 잠금 조회(FOR UPDATE)는 스냅숏을 만들지 않는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.userId = :userId") + List lockAllByUserId(@Param("userId") Long userId); + + /** + * 삭제 대상 폴더를 기본 키로 배타 잠금(FOR UPDATE)한다. (#319) + * + *

{@link #lockAllByUserId} 와 달리 {@code idx_folder_user_id} 등치 스캔을 타지 않아 + * next-key lock 의 갭이 인접 사용자 구간까지 덮지 않는다. 기본 키 등치 조회는 {@code REC_NOT_GAP} 이다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.id in :folderIds") + List lockAllByIdIn(@Param("folderIds") Collection folderIds); + + /** + * 주어진 폴더들의 바로 아래 하위 폴더를 배타 잠금(FOR UPDATE)으로 읽는다. (#319) + * + *

삭제 대상 서브트리를 한 단계씩 내려가며 잠그는 데 쓴다. 일반 조회로 내려가면 안 된다. + * 일반 조회는 그 시점의 스냅숏을 고정하므로, 아직 잠그지 못한 하위 폴더에 그 뒤로 커밋된 + * 문제나 폴더가 보이지 않아 #233 의 고아 데이터가 그대로 돌아온다. 잠금 조회는 스냅숏이 아니라 + * 최신 행을 읽는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.parentFolder.id in :parentFolderIds") + List lockAllByParentFolderIdIn(@Param("parentFolderIds") Collection parentFolderIds); + + /** + * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. + * + *

{@code FolderService.initializeDefaultFoldersIfAbsent} 가 가입 시점에 루트 폴더와 기본 하위 + * 폴더를 자동으로 만든다. 전부 세면 아무것도 안 한 사람이 2/10 에서 시작해 "폴더를 열 개나 만들어 + * 정리했어요" 라는 말과 안 맞는다. + * + *

기본 하위 폴더까지 빼지 않는 이유는 그쪽은 이름으로만 거를 수 있기 때문이다. 사용자가 이름을 + * 바꾸는 순간 판정이 달라진다. 루트는 {@code parentFolder IS NULL} 이라는 구조로 걸러져 이름과 무관하다. + * 그래서 갓 가입한 사람은 1/10 에서 시작하고, 그 하나는 실제로 자기 폴더 트리에 있는 폴더다. + * + *

소프트 삭제된 폴더는 {@code @SQLRestriction} 이 걸러 준다. + */ + long countByUserIdAndParentFolderIsNotNull(Long userId); + @Modifying @Query("delete from Folder f where f.id in :folderIds") void deleteAllByIdIn(@Param("folderIds") Collection folderIds); + /** + * 폴더를 소프트 삭제한다. {@code @SQLDelete} 와 같은 결과를 내는 벌크 UPDATE 다. + * + *

{@code deleteAll(entities)} 로 지우면 삭제가 조용히 취소되는 경우가 있었다. + * {@code Folder.subFolderList} 가 {@code CascadeType.ALL} 이라, 삭제 대상 폴더가 + * 영속성 컨텍스트에 남은 다른 폴더의 초기화된 하위 폴더 컬렉션에서 여전히 참조되면 + * 플러시 시점의 cascade persist 가 삭제 예약을 되돌린다(엔티티 부활). 그 결과 + * "폴더 두 개를 지웠는데 아무것도 지워지지 않는" 상태가 됐다. + * + *

벌크 UPDATE 는 영속성 컨텍스트의 cascade 를 타지 않으므로 항상 그대로 실행된다. + * 앞선 변경(문제 소프트 삭제 등)을 먼저 반영하기 위해 flush 하고, 실행 후에는 남아 있는 + * 엔티티가 삭제 사실을 모르는 상태이므로 컨텍스트를 비운다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update Folder f set f.deletedAt = CURRENT_TIMESTAMP where f.id in :folderIds") + void softDeleteAllByIdIn(@Param("folderIds") Collection folderIds); + @Query(""" SELECT p.folder.id, COUNT(p.id) FROM Problem p diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index d6f35370..8e7a26f9 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -74,7 +74,7 @@ public FolderResponseDto findFolder(Long folderId, Long userId) { return toFolderResponseDto(folder, problemIdList); } - @Transactional(readOnly = true) + // 호출자의 트랜잭션 안에서 실행된다. private 에 붙인 @Transactional 은 프록시가 가로채지 못해 무효다. private Folder findFolderEntity(Long folderId) { return folderRepository.findById(folderId) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); @@ -87,6 +87,25 @@ public Folder findFolderEntity(Long folderId, Long userId) { return folder; } + /** + * 부모로 삼을 폴더를 공유 잠금(FOR SHARE)으로 읽는다. (#233) + * + *

문제 등록이 폴더를 잠그고 읽는 것과 같은 이유다. 잠금 없이 읽으면 아직 커밋되지 않은 폴더 삭제를 + * 못 보고 그 폴더 아래에 새 폴더를 만들거나 기존 폴더를 옮긴다. 삭제가 커밋된 뒤에는 삭제된 폴더를 + * 부모로 가리키는 살아 있는 폴더가 남아, 앱에서 어느 폴더에도 보이지 않는다. + * + *

잠금 조회는 스냅숏이 아니라 최신 행을 읽으므로, 삭제가 먼저 잡았으면 커밋까지 기다렸다가 + * {@code deleted_at} 이 찍힌 행을 보고 {@code @SQLRestriction} 에 걸려 기존 {@code FOLDER_NOT_FOUND} 가 된다. + * + *

호출자의 트랜잭션 안에서 실행된다. + */ + private Folder findParentFolderForShare(Long folderId, Long userId) { + Folder parentFolder = folderRepository.findByIdForShare(folderId) + .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); + validateFolderOwner(parentFolder, userId); + return parentFolder; + } + @Transactional(readOnly = true) public List findAllUserFolderThumbnails(Long userId) { List folderList = folderRepository.findAllByUserId(userId); @@ -130,8 +149,15 @@ private void createDefaultSubFolder(Folder rootFolder, Long userId) { } public Long createFolder(FolderRegisterDto folderRegisterDto, Long userId) { + // parentFolderId 가 없으면 findById(null) 이 IllegalArgumentException 을 던져 500 으로 나갔다. + // 루트 폴더는 initializeDefaultFoldersIfAbsent 만 만들 수 있으므로, 부모 없는 생성 요청은 + // 잘못된 요청으로 보고 다른 "부모 폴더를 찾을 수 없음"과 같은 응답을 준다. + if (folderRegisterDto.parentFolderId() == null) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + Folder folder = Folder.from(folderRegisterDto, userId); - Folder parentFolder = findFolderEntity(folderRegisterDto.parentFolderId(), userId); + Folder parentFolder = findParentFolderForShare(folderRegisterDto.parentFolderId(), userId); folder.updateParentFolder(parentFolder); folderRepository.save(folder); @@ -150,7 +176,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { folder.updateFolderInfo(folderRegisterDto); if (folderRegisterDto.parentFolderId() != null && folder.getParentFolder() != null) { - Folder newParentFolder = findFolderEntity(folderRegisterDto.parentFolderId(), userId); + Folder newParentFolder = findParentFolderForShare(folderRegisterDto.parentFolderId(), userId); + validateNotCyclic(folder, newParentFolder); folder.updateParentFolder(newParentFolder); } @@ -159,8 +186,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { } public void deleteFoldersWithProblems(Long userId, List folderIds) { - // 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함) - Set allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds); + // 삭제 대상 서브트리를 잠그면서 하위 폴더까지 모은다. 이 줄보다 앞에 조회를 두면 안 된다. (#233, #319) + Set allFolderIds = lockSubtreeAndCollectFolderIds(userId, folderIds); problemService.deleteAllByFolderIds(userId, allFolderIds); @@ -168,42 +195,91 @@ public void deleteFoldersWithProblems(Long userId, List folderIds) { } public void deleteAllUserFoldersWithProblems(Long userId) { + // 폴더 하나씩 지우는 경로와 같은 이유로 사용자 폴더 전체를 먼저 잡는다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) + folderRepository.lockAllByUserId(userId); problemService.deleteAllUserProblems(userId); deleteAllUserFolders(userId); } - public Set getAllFolderIdsIncludingSubFolders(Long userId, List folderIds) { - Set allFolderIds = new HashSet<>(); + /** + * 삭제 대상 폴더와 그 하위 폴더 전부를 배타 잠금으로 잡으면서 ID 를 모은다. (#233, #319) + * + *

호출자의 트랜잭션에서 가장 먼저 실행돼야 한다. 여기서 쓰는 조회는 전부 잠금 조회라 + * REPEATABLE READ 스냅숏을 고정하지 않는다. 그래서 잠금을 다 잡은 뒤에 일어나는 일반 조회가 + * "잠금을 잡은 시점 이후" 를 보게 되고, 잠금을 기다리다 커밋된 등록도 삭제 대상에 들어온다. + * 이 앞에 일반 조회를 한 줄이라도 두면 그 순간 스냅숏이 박혀 #233 의 고아 문제가 되살아난다. + * + *

한 단계씩 내려가도 빠지는 폴더는 없다. 어떤 폴더 아래에 새 폴더를 만들거나 옮기려면 + * {@link #findParentFolderForShare} 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 + * 그쪽이 기다렸다가 삭제된 부모를 보고 거절된다. 아직 안 잠근 단계에서 먼저 들어온 생성은 + * 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아낸다. + * + *

사용자 폴더 전체를 잡던 예전 방식({@code lockAllByUserId})은 삭제와 무관한 폴더로 들어오는 + * 등록까지 줄 세웠고, 기다리는 요청이 커넥션을 문 채로 풀을 바닥내 다른 사용자까지 죽였다. (#319) + */ + private Set lockSubtreeAndCollectFolderIds(Long userId, List folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Set.of(); + } - for (Long folderId : folderIds) { - Folder folder = findFolderEntity(folderId, userId); + Map lockedTargets = folderRepository.lockAllByIdIn(new LinkedHashSet<>(folderIds)).stream() + .collect(Collectors.toMap(Folder::getId, folder -> folder)); + // 검증 순서는 예전과 같게 요청받은 순서대로 본다. 없음 → 소유자 불일치 → 루트 순이다. + for (Long folderId : folderIds) { + Folder folder = lockedTargets.get(folderId); + if (folder == null) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + validateFolderOwner(folder, userId); if (folder.getParentFolder() == null) { throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_REMOVE); } - allFolderIds.add(folder.getId()); - allFolderIds.addAll(getSubFolderIdsRecursive(folder)); + } + + // 이미 잠근 폴더는 다시 타고 들어가지 않는다. 부모-자식에 순환이 남아 있어도(과거 데이터) 한 번만 훑는다. + Set allFolderIds = new LinkedHashSet<>(lockedTargets.keySet()); + Collection currentLevel = new ArrayList<>(allFolderIds); + + while (!currentLevel.isEmpty()) { + List nextLevel = new ArrayList<>(); + for (Folder subFolder : folderRepository.lockAllByParentFolderIdIn(currentLevel)) { + if (allFolderIds.add(subFolder.getId())) { + nextLevel.add(subFolder.getId()); + } + } + currentLevel = nextLevel; } return allFolderIds; } - private Set getSubFolderIdsRecursive(Folder folder) { - Set subFolderIds = new HashSet<>(); + /** + * 폴더를 자기 자신이나 자기 하위 폴더 아래로 옮기려는 요청을 막는다. + * + *

막지 않으면 트리에 순환이 생겨 폴더 삭제(하위 폴더 재귀 수집)와 앱의 폴더 탐색이 + * 무한 루프에 빠진다. 새 부모에서 루트 방향으로 거슬러 올라가며 자기 자신이 나오는지 본다. + */ + private void validateNotCyclic(Folder folder, Folder newParentFolder) { + Set visitedFolderIds = new HashSet<>(); + Folder ancestor = newParentFolder; - for (Folder subFolder : folder.getSubFolderList()) { - subFolderIds.add(subFolder.getId()); - subFolderIds.addAll(getSubFolderIdsRecursive(subFolder)); + while (ancestor != null && visitedFolderIds.add(ancestor.getId())) { + if (Objects.equals(ancestor.getId(), folder.getId())) { + throw new ApplicationException(FolderErrorCase.INVALID_PARENT_FOLDER); + } + ancestor = ancestor.getParentFolder(); } - - return subFolderIds; } private void deleteAllByFolderIds(Collection folderIds) { - List foldersToDelete = folderRepository.findAllById(folderIds); - folderRepository.deleteAll(foldersToDelete); + if (folderIds.isEmpty()) { + return; + } + + folderRepository.softDeleteAllByIdIn(folderIds); } public void deleteAllUserFolders(Long userId) { diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java index 2295e6bf..a2b75082 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -12,4 +13,12 @@ public interface LearningCalendarMoodRepository extends JpaRepository findByUserIdAndStudyDate(Long userId, LocalDate studyDate); List findAllByUserIdAndStudyDateBetween(Long userId, LocalDate startDate, LocalDate endDate); + + /** + * 오늘 이 사용자가 기분을 남긴 적이 있는지. 어느 날짜에 남겼는지가 아니라 언제 남겼는지를 본다. + * + *

미션 진행도를 하루 한 번만 올리기 위한 판정이다. 자세한 이유는 + * {@code LearningCalendarService.updateMood} 주석에 있다. + */ + boolean existsByUserIdAndCreatedAtBetween(Long userId, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java index 7ef7b47f..efed8fcf 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarQueryRepository.java @@ -11,6 +11,7 @@ import java.sql.Date; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import static com.aisip.OnO.backend.problem.entity.QProblem.problem; @@ -19,6 +20,14 @@ @Repository public class LearningCalendarQueryRepository { + /** + * 하루의 마지막 순간. 집계 쿼리가 {@code BETWEEN start AND end} 로 도는데 + * {@code 23:59:59} 로 끊으면 {@code datetime(6)} 컬럼에 저장된 + * 23:59:59.000001 ~ 23:59:59.999999 구간의 기록이 통째로 빠진다. + * 마이크로초 단위까지 포함하도록 경계를 잡는다. + */ + private static final LocalTime END_OF_DAY = LocalTime.of(23, 59, 59, 999_999_000); + private final JPAQueryFactory queryFactory; public LearningCalendarQueryRepository(EntityManager entityManager) { @@ -130,7 +139,7 @@ public List findDistinctNoteWriteDatesTotal(Long userId) { public boolean existsStudyRecord(Long userId, LocalDate date) { LocalDateTime start = date.atStartOfDay(); - LocalDateTime end = date.atTime(23, 59, 59); + LocalDateTime end = date.atTime(END_OF_DAY); Integer reviewExists = queryFactory .selectOne() .from(problemSolve) diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java index 9b4ceaa1..bf92e623 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -9,6 +9,8 @@ import com.aisip.OnO.backend.learningcalendar.exception.LearningCalendarErrorCase; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarMoodRepository; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,11 +18,14 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.YearMonth; +import java.time.ZoneId; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.TreeSet; import java.util.stream.Collectors; @@ -29,13 +34,26 @@ @Transactional(readOnly = true) public class LearningCalendarService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** + * 하루의 마지막 순간. 집계 쿼리가 {@code BETWEEN start AND end} 로 도는데 + * {@code 23:59:59} 로 끊으면 {@code datetime(6)} 컬럼에 저장된 + * 23:59:59.000001 ~ 23:59:59.999999 구간의 기록이 통째로 빠진다. + * 마이크로초 단위까지 포함하도록 경계를 잡는다. + */ + private static final LocalTime END_OF_DAY = LocalTime.of(23, 59, 59, 999_999_000); + private final LearningCalendarQueryRepository calendarRepository; private final StreakCacheService streakCacheService; private final LearningCalendarMoodRepository moodRepository; private final CustomEmojiValidator customEmojiValidator; + private final MissionProgressUpdater missionProgressUpdater; public LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month) { - return getLearningCalendar(userId, year, month, LocalDate.now()); + // 서비스 대상이 국내 사용자이고 problem/studyroom 등 다른 도메인도 KST 기준으로 하루를 가른다. + // JVM 기본 시간대를 쓰면 배포 환경에 따라 스트릭의 "오늘"이 하루 어긋난다. + return getLearningCalendar(userId, year, month, LocalDate.now(KST)); } LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month, LocalDate today) { @@ -43,7 +61,7 @@ LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month LocalDate startDate = yearMonth.atDay(1); LocalDate endDate = yearMonth.atEndOfMonth(); LocalDateTime start = startDate.atStartOfDay(); - LocalDateTime end = endDate.atTime(23, 59, 59); + LocalDateTime end = endDate.atTime(END_OF_DAY); Map reviewStats = toReviewStatMap( calendarRepository.findDailyReviewStats(userId, start, end) @@ -93,9 +111,29 @@ public LearningCalendarMoodResponseDto updateMood(Long userId, LearningCalendarM if (!calendarRepository.existsStudyRecord(userId, request.date())) { throw new ApplicationException(LearningCalendarErrorCase.CALENDAR_RECORD_NOT_FOUND); } - LearningCalendarMood mood = moodRepository.findByUserIdAndStudyDate(userId, request.date()) + Optional existingMood = moodRepository.findByUserIdAndStudyDate(userId, request.date()); + + // 기분 미션은 "오늘 처음 기분을 남겼을 때" 한 번만 오른다. + // + // 판정 기준이 요청의 date() 가 아니라 기록이 만들어진 시각인 데는 이유가 있다. + // 예전에는 date() 가 서버의 오늘(KST)과 같은지를 봤는데, 그러면 기기 시간대가 KST 가 아닌 + // 해외 사용자는 자기 기준 오늘을 보내도 서버 기준으로는 어제라서 미션이 영영 오르지 않는다. + // 그렇다고 date() 검사를 빼면 지난 날짜 세 곳에 기분을 남기는 것만으로 오늘 진행도가 3 오른다. + // + // "오늘 만들어진 기분 기록이 이미 있는가"로 보면 둘 다 해결된다. 사용자가 어느 날짜에 남기든 + // 하루에 한 번만 오르고, 기기 시간대와 무관하다. 이모지만 바꾸는 경우도 새 기록이 아니라 오르지 않는다. + LocalDate today = LocalDate.now(KST); + boolean firstMoodOfToday = existingMood.isEmpty() && !moodRepository.existsByUserIdAndCreatedAtBetween( + userId, today.atStartOfDay(), today.atTime(END_OF_DAY)); + + LearningCalendarMood mood = existingMood .orElseGet(() -> moodRepository.save(LearningCalendarMood.create(userId, request.date(), request.emojiKey()))); mood.updateEmojiKey(request.emojiKey()); + + if (firstMoodOfToday) { + missionProgressUpdater.increase(userId, MissionMetric.MOOD_LOGGED); + } + return new LearningCalendarMoodResponseDto(mood.getStudyDate(), mood.getEmojiKey()); } diff --git a/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java index a330a2ad..d2590e5c 100644 --- a/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java +++ b/src/main/java/com/aisip/OnO/backend/learningreport/service/LearningReportService.java @@ -19,6 +19,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneId; @@ -39,6 +40,14 @@ public class LearningReportService { private static final int MONTHLY_REVIEW_GOAL = 30; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + /** + * 하루의 마지막 순간. 집계 쿼리가 {@code BETWEEN start AND end} 로 도는데 + * {@code 23:59:59} 로 끊으면 {@code datetime(6)} 컬럼에 저장된 + * 23:59:59.000001 ~ 23:59:59.999999 구간의 기록이 통째로 빠진다. + * 마이크로초 단위까지 포함하도록 경계를 잡는다. + */ + private static final LocalTime END_OF_DAY = LocalTime.of(23, 59, 59, 999_999_000); + private final LearningReportQueryRepository reportRepository; private final OpenAIClient openAIClient; private final RedisSingleDataService redisSingleDataService; @@ -46,7 +55,7 @@ public class LearningReportService { public LearningReportResponseDto getLearningReport(Long userId, LocalDate baseDate) { LocalDate targetDate = resolveTargetDate(baseDate); - String cacheKey = buildCacheKey(userId, LocalDate.now(), targetDate); + String cacheKey = buildCacheKey(userId, LocalDate.now(KST), targetDate); LearningReportResponseDto cached = readCache(cacheKey); if (cached != null) { @@ -95,9 +104,9 @@ public LearningReportSummaryResponseDto getLearningReportSummary(Long userId) { YearMonth previousMonth = currentMonth.minusMonths(1); LocalDateTime currentStart = currentMonth.atDay(1).atStartOfDay(); - LocalDateTime currentEnd = currentMonth.atEndOfMonth().atTime(23, 59, 59); + LocalDateTime currentEnd = currentMonth.atEndOfMonth().atTime(END_OF_DAY); LocalDateTime previousStart = previousMonth.atDay(1).atStartOfDay(); - LocalDateTime previousEnd = previousMonth.atEndOfMonth().atTime(23, 59, 59); + LocalDateTime previousEnd = previousMonth.atEndOfMonth().atTime(END_OF_DAY); long monthlyReviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, currentStart, currentEnd)); long previousMonthlyReviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, previousStart, previousEnd)); @@ -178,7 +187,7 @@ private LearningPeriodReport buildPeriodReport( Long userId, String label, DateRange range, TrendType trendType ) { LocalDateTime start = range.start().atStartOfDay(); - LocalDateTime end = range.end().atTime(23, 59, 59); + LocalDateTime end = range.end().atTime(END_OF_DAY); Long reviewCount = defaultLong(reportRepository.countReviewsInPeriod(userId, start, end)); Long noteWriteCount = defaultLong(reportRepository.countNoteWritesInPeriod(userId, start, end)); @@ -249,7 +258,7 @@ private LearningComparison buildComparison( private List buildTrend(Long userId, LocalDate startDate, LocalDate endDate, TrendType trendType) { LocalDateTime start = startDate.atStartOfDay(); - LocalDateTime end = endDate.atTime(23, 59, 59); + LocalDateTime end = endDate.atTime(END_OF_DAY); Map bucket = initializeTrendBuckets(startDate, endDate, trendType); @@ -400,9 +409,16 @@ private LearningRecommendations buildRecommendations( summaryPayload.put("monthlyComparison", monthlyComparison); summaryPayload.put("ruleBasedRecommendations", fallback); - return openAIClient.recommendLearningReport(summaryPayload) - .map(ai -> mergeRecommendations(fallback, ai)) - .orElse(fallback); + // AI 추천은 리포트의 부가 정보다. 외부 호출이 어떤 이유로 실패하더라도 + // 집계 결과 자체는 그대로 내려가야 하므로 룰 기반 결과로 대체한다. + try { + return openAIClient.recommendLearningReport(summaryPayload) + .map(ai -> mergeRecommendations(fallback, ai)) + .orElse(fallback); + } catch (Exception e) { + log.warn("Failed to build AI learning recommendations. userId={}, reason={}", userId, e.getMessage()); + return fallback; + } } private LearningRecommendations mergeRecommendations(LearningRecommendations fallback, LearningRecommendations ai) { @@ -521,7 +537,9 @@ private record DateRange(LocalDate start, LocalDate end) { } private LocalDate resolveTargetDate(LocalDate baseDate) { - return baseDate == null ? LocalDate.now().minusDays(1) : baseDate; + // 요약 조회(getLearningReportSummary)와 마찬가지로 KST 기준 날짜를 쓴다. + // JVM 기본 시간대를 쓰면 배포 환경에 따라 기준일이 하루 어긋난다. + return baseDate == null ? LocalDate.now(KST).minusDays(1) : baseDate; } private String buildCacheKey(Long userId, LocalDate requestDate, LocalDate targetDate) { diff --git a/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java new file mode 100644 index 00000000..481908c3 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.mission.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; +import com.aisip.OnO.backend.mission.service.MissionService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/missions") +public class MissionController { + + private final MissionService missionService; + + @GetMapping("") + public CommonResponse getMissions() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.getMissions(userId)); + } + + /** + * 지금까지 받은 보상 기록. 합계는 첫 페이지(커서 없음)에만 실린다. + */ + @GetMapping("/history") + public CommonResponse getClaimHistory( + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.getClaimHistory(userId, cursor, size)); + } + + @PostMapping("/{progressId}/claim") + public CommonResponse claim(@PathVariable Long progressId) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.claim(userId, progressId)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java new file mode 100644 index 00000000..510d91ab --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java @@ -0,0 +1,43 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +import java.time.LocalDateTime; + +/** + * 보상을 받은 기록 한 건. + * + *

새 테이블을 두지 않는다. {@code mission_progress.claimed_at} 이 이미 그 기록이다. + * + *

보상 종류와 값은 받은 시점의 스냅샷에서 읽는다. 현재 정의를 읽으면 운영 중에 보상을 바꿨을 때 + * 예전에 받은 기록까지 새 값으로 보인다. 스냅샷이 없는 옛 행만 현재 정의로 폴백한다. + */ +public record MissionClaimHistoryItemDto( + Long progressId, + String code, + String title, + String iconKey, + MissionCategory category, + String periodKey, + MissionRewardType rewardType, + int rewardValue, + LocalDateTime claimedAt +) { + + public static MissionClaimHistoryItemDto from(MissionDefinition definition, MissionProgress progress) { + return new MissionClaimHistoryItemDto( + progress.getId(), + definition.getCode(), + definition.getTitle(), + definition.getIconKey(), + definition.getCategory(), + progress.getPeriodKey(), + progress.rewardTypeOr(definition.getRewardType()), + progress.rewardValueOr(definition.getRewardValue()), + progress.getClaimedAt() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java new file mode 100644 index 00000000..aa42bd23 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.mission.dto; + +import java.util.List; + +/** + * 보상 획득 기록 페이지. + * + *

{@code content}/{@code nextCursor}/{@code hasNext}/{@code size} 는 + * {@code CursorPageResponse} 와 이름과 의미가 같다. 프론트가 같은 방식으로 파싱한다. + * 합계 두 개를 더 실어야 해서 레코드를 따로 뒀을 뿐이다. + * + *

합계는 첫 페이지에만 채운다. 페이지를 넘길 때마다 전체를 다시 세는 것은 낭비이고, + * 화면 맨 위의 "지금까지 받은 XP" 는 첫 페이지 값만 쓴다. 이후 페이지에서는 두 값이 null 이다. + */ +public record MissionClaimHistoryResponseDto( + List content, + Long nextCursor, + boolean hasNext, + int size, + Long totalClaimedXp, + Long totalClaimedCount +) { + + public static MissionClaimHistoryResponseDto firstPage( + List content, Long nextCursor, boolean hasNext, int size, + long totalClaimedXp, long totalClaimedCount + ) { + return new MissionClaimHistoryResponseDto( + content, nextCursor, hasNext, size, totalClaimedXp, totalClaimedCount); + } + + public static MissionClaimHistoryResponseDto nextPage( + List content, Long nextCursor, boolean hasNext, int size + ) { + return new MissionClaimHistoryResponseDto(content, nextCursor, hasNext, size, null, null); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java new file mode 100644 index 00000000..df705ffe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +import java.util.List; + +/** + * 보상 수령 결과. 지급 직후의 총 학습 레벨과 이번 지급으로 레벨이 올랐는지를 담는다. + * + *

{@code unlockedCosmetics} 는 이번 지급으로 레벨이 올라 새로 열린 꾸미기 아이템이다. + * 기존 필드는 그대로 두고 뒤에 덧붙였다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. + * 레벨이 안 올랐으면 빈 배열이고, 여러 단계 올랐으면 그 사이에 열린 것이 전부 들어온다. + * null 이 아니라 빈 배열로 내려보낸다. 프론트가 null 검사를 따로 하지 않아도 되게. + */ +public record MissionClaimResponseDto( + Long progressId, + MissionRewardType rewardType, + int rewardValue, + Long totalStudyLevel, + boolean leveledUp, + List unlockedCosmetics +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java new file mode 100644 index 00000000..b78da95a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.mission.dto; + +import java.util.List; + +/** + * 미션 목록 응답. 일일과 주간을 각자의 기간 키와 함께 나눠 내려준다. + * + *

{@code expired} 는 지난 기간에 완료했지만 아직 받지 않은 보상이다. 이 묶음이 없으면 + * 일요일 밤에 주간 미션을 끝내고 받지 않은 채 앱을 닫은 사용자는 월요일부터 그 미션이 목록에서 사라져 + * 보상을 영영 받을 방법이 없다. 받기는 {@code progressId} 로 하므로 기간이 지나도 그대로 동작한다. + */ +public record MissionListResponseDto( + MissionSectionDto daily, + MissionSectionDto weekly, + MissionSectionDto expired +) { + + /** + * @param periodKey 그 묶음 전체의 기간 키. 여러 기간이 섞이는 {@code expired} 에서는 null 이고, + * 기간은 항목마다 실린 {@code periodKey} 로 확인한다. + */ + public record MissionSectionDto( + String periodKey, + List missions + ) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java new file mode 100644 index 00000000..64a6f7a6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java @@ -0,0 +1,52 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +/** + * 미션 한 건. + * + *

{@code progressId} 는 아직 한 번도 손대지 않은 미션에서 null 이다. 받기는 이 값으로 하는데, + * 완료된 미션에는 반드시 진행도 행이 있으므로 완료 상태에서 null 이 되는 경우는 없다. + * + *

{@code periodKey} 를 항목마다 싣는다. expired 묶음에는 서로 다른 기간의 미션이 함께 들어오므로 + * 묶음 하나에 기간 키 하나를 다는 것으로는 "언제 완료한 것인지"를 표현할 수 없다. + */ +public record MissionResponseDto( + Long progressId, + String periodKey, + String code, + String title, + String description, + String iconKey, + MissionCategory category, + int current, + int target, + boolean completed, + boolean claimed, + MissionRewardType rewardType, + int rewardValue +) { + + public static MissionResponseDto from(MissionDefinition definition, MissionProgress progress, String periodKey) { + return new MissionResponseDto( + progress == null ? null : progress.getId(), + periodKey, + definition.getCode(), + definition.getTitle(), + definition.getDescription(), + definition.getIconKey(), + definition.getCategory(), + progress == null ? 0 : progress.getCurrentValue(), + // 진행도가 있으면 그 행이 만들어질 때 박아둔 목표로 보여준다. + // 운영 중에 목표를 낮추면 이미 채운 사람의 화면이 "5 / 3" 이 되기 때문이다. + progress == null ? definition.getTarget() : progress.getTargetSnapshot(), + progress != null && progress.isCompleted(), + progress != null && progress.isClaimed(), + definition.getRewardType(), + definition.getRewardValue() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java new file mode 100644 index 00000000..2adbb132 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.mission.entity; + +/** + * 미션이 어떤 주기로 초기화되는지. + * + *

초기화는 배치가 아니라 {@code period_key} 로 한다. DAILY 는 {@code yyyy-MM-dd}, + * WEEKLY 는 ISO 주차 {@code yyyy-'W'ww} 를 키로 쓰므로 기간이 바뀌면 조회에서 자연히 빠진다. + */ +public enum MissionCategory { + DAILY, + WEEKLY +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java new file mode 100644 index 00000000..97595e0a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 미션 정의. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. + * + *

정의를 코드가 아니라 DB 에 두는 이유는 목표치나 보상을 바꿀 때 배포 없이 고치기 위해서다. + * 다만 진행 중이던 사용자가 바뀐 목표에 휘말리면 안 되므로, 진행도 행은 생성 시점의 + * {@code target} 을 {@code target_snapshot} 으로 복사해 두고 그 값으로 완료를 판정한다. + */ +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mission_definition", + uniqueConstraints = @UniqueConstraint(name = "uk_mission_definition_code", columnNames = "code")) +public class MissionDefinition extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, length = 60) + private String code; + + @Column(name = "title", nullable = false, length = 60) + private String title; + + @Column(name = "description", nullable = false, length = 200) + private String description; + + @Column(name = "icon_key", nullable = false, length = 40) + private String iconKey; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private MissionCategory category; + + @Enumerated(EnumType.STRING) + @Column(name = "metric", nullable = false, length = 40) + private MissionMetric metric; + + @Column(name = "target", nullable = false) + private int target; + + @Enumerated(EnumType.STRING) + @Column(name = "reward_type", nullable = false, length = 20) + private MissionRewardType rewardType; + + @Column(name = "reward_value", nullable = false) + private int rewardValue; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(name = "active", nullable = false) + private boolean active; +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java index 4de17852..95ede576 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java @@ -31,15 +31,38 @@ public class MissionLog extends BaseEntity { private MissionType missionType; + /** + * 이 기록으로 자동 적립 경로가 열렸는가. 열렸으면 그 행동의 정가, 아니면 0 이다. + * + *

예전에는 지급 여부와 무관하게 언제나 정가가 들어갔다. 미션을 받을 수 있는 앱에서 온 요청은 + * 자동 적립을 돌리지 않는데도 정가가 들어가는 바람에, 하루 200점 상한({@code getPointSumToday})이 + * 받지도 않은 점수로 채워져 같은 계정의 구버전 기기 적립까지 막았다(#318). + * + *

상한 계산은 이 값의 합만 본다. 그래서 실제로 적립이 돈 행만 상한을 갉는다. + * 적립이 돈 행에는 상한에 걸려 깎이기 전 정가를 그대로 넣는다. 깎인 실지급액을 넣으면 + * 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀐다. + * + *

관리자 복습 로그 화면이 보여 주는 점수는 이 값이 아니라 미션 타입의 정가다 + * ({@code AdminPracticeLogResponseDto}). 화면의 뜻은 "이 행동의 값어치"라 적립 여부와 무관하다. + */ private Long point; private Long referenceId; + /** 자동 적립이 도는 요청에서 남기는 기록. 정가가 그대로 들어간다. */ public static MissionLog from(MissionRegisterDto missionRegisterDto, User user) { + return from(missionRegisterDto, user, true); + } + + /** + * @param accrued 이 요청에서 자동 적립이 도는가. 돌지 않으면 {@link #point} 를 0 으로 남겨 + * 하루 상한과 중복 방지 판정이 이 행을 "적립된 행"으로 세지 않게 한다. + */ + public static MissionLog from(MissionRegisterDto missionRegisterDto, User user, boolean accrued) { return MissionLog.builder() .user(user) .missionType(missionRegisterDto.missionType()) - .point(missionRegisterDto.missionType().getPoint()) + .point(accrued ? missionRegisterDto.missionType().getPoint() : 0L) .referenceId(missionRegisterDto.referenceId()) .build(); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java new file mode 100644 index 00000000..55e06ab5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java @@ -0,0 +1,48 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import lombok.Getter; + +/** + * 미션이 세는 항목. + * + *

각 항목은 보상을 어느 능력치에 넣을지도 함께 정한다. 기존 {@link MissionType} 의 적립 규칙과는 + * 별개다. 미션 보상은 기존 적립 위에 얹는 보너스라 하루 200점 상한을 타지 않는다. + */ +@Getter +public enum MissionMetric { + + /** 출석. 하루에 한 번만 오른다. */ + LOGIN_DAY(AbilityType.ATTENDANCE, true), + + /** 오답노트 등록. 여러 장을 한 번에 등록하면 장수만큼 오른다. */ + PROBLEM_CREATED(AbilityType.NOTE_WRITE, true), + + /** 복습 기록. */ + SOLVE_RECORDED(AbilityType.PROBLEM_PRACTICE, true), + + /** 복습 기록 중 정답. */ + SOLVE_CORRECT(AbilityType.PROBLEM_PRACTICE, true), + + /** 복습 세트 완료. */ + PRACTICE_NOTE_COMPLETED(AbilityType.NOTE_PRACTICE, true), + + /** 학습 달력 기분 저장. 하루에 한 번만 오른다. 기분 저장은 자동 적립을 부르지 않는다. */ + MOOD_LOGGED(AbilityType.ATTENDANCE, false); + + private final AbilityType abilityType; + + /** + * 이 항목을 올리는 행동이 자동 적립({@code MissionLogService})도 함께 부르는가. + * + *

{@code true} 인 항목은 자동 적립이 도는 요청에서 진행도를 올리지 않는다. 올리면 같은 행동으로 + * 적립 XP 와 미션 보상 XP 를 둘 다 받는다. 새 항목을 추가할 때 이 값을 반드시 정해야 하도록 + * 생성자 인자로 두었다. + */ + private final boolean alsoAccruedByLegacyPath; + + MissionMetric(AbilityType abilityType, boolean alsoAccruedByLegacyPath) { + this.abilityType = abilityType; + this.alsoAccruedByLegacyPath = alsoAccruedByLegacyPath; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java new file mode 100644 index 00000000..fdef0833 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java @@ -0,0 +1,107 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자별 미션 진행도. (userId, missionId, periodKey) 하나당 한 행이다. + * + *

진행도 증가는 이 엔티티를 통하지 않는다. 읽고 나서 쓰면 같은 사용자의 요청이 겹칠 때 + * 증가분이 사라지므로, {@code MissionProgressRepository} 의 upsert 한 문장으로만 올린다. + * 이 클래스는 조회와 받기 판정에서만 쓴다. + * + *

user 와 mission_definition 에 연관관계를 걸지 않고 식별자만 들고 있다. 외래키를 걸면 + * INSERT 마다 부모 행에 공유 잠금이 붙어, 이미 사용자 행을 배타 잠금으로 잡고 있는 + * 기존 미션 적립 경로와 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mission_progress", + uniqueConstraints = @UniqueConstraint( + name = "uk_mission_progress", + columnNames = {"user_id", "mission_id", "period_key"}), + indexes = { + @Index(name = "idx_mission_progress_lookup", columnList = "user_id, period_key"), + // 보상 획득 기록은 받은 시각 역순으로만 읽는다. 정렬 키를 뒤에 둬 정렬을 생략시킨다. + @Index(name = "idx_mission_progress_claimed", columnList = "user_id, claimed_at") + }) +public class MissionProgress extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "mission_id", nullable = false) + private Long missionId; + + @Column(name = "period_key", nullable = false, length = 20) + private String periodKey; + + @Column(name = "current_value", nullable = false) + private int currentValue; + + @Column(name = "target_snapshot", nullable = false) + private int targetSnapshot; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "claimed_at") + private LocalDateTime claimedAt; + + /** + * 받은 시점의 보상 종류와 값. + * + *

기록 조회가 현재 정의를 읽으면 운영 중에 보상을 바꿨을 때 예전 기록까지 새 값으로 보인다. + * {@code targetSnapshot} 과 같은 이유로 받는 순간의 값을 박아 둔다. + * 이 기능 이전에 받은 행이 있을 수 있어 nullable 이고, 비어 있으면 현재 정의로 폴백한다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "reward_type_snapshot", length = 20) + private MissionRewardType rewardTypeSnapshot; + + @Column(name = "reward_value_snapshot") + private Integer rewardValueSnapshot; + + public boolean isOwnedBy(Long userId) { + return this.userId != null && this.userId.equals(userId); + } + + public boolean isCompleted() { + return completedAt != null; + } + + public boolean isClaimed() { + return claimedAt != null; + } + + /** 받은 시점의 보상 종류. 스냅샷이 없는 옛 행은 현재 정의 값으로 답한다. */ + public MissionRewardType rewardTypeOr(MissionRewardType current) { + return rewardTypeSnapshot != null ? rewardTypeSnapshot : current; + } + + /** 받은 시점의 보상 값. 스냅샷이 없는 옛 행은 현재 정의 값으로 답한다. */ + public int rewardValueOr(int current) { + return rewardValueSnapshot != null ? rewardValueSnapshot : current; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java new file mode 100644 index 00000000..5875f34c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.mission.entity; + +/** + * 미션 보상 종류. 1차는 XP 뿐이고 테마·칭호는 2차다. + */ +public enum MissionRewardType { + XP +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java index 504326ff..d5ca6227 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java @@ -10,6 +10,34 @@ @NoArgsConstructor @AllArgsConstructor public class UserMissionStatus { + + /** + * 총 학습 레벨 상한. + * + *

치장 해금표가 총 학습 레벨 20 까지 아이템을 두고 있어 15 에서 20 으로 올렸다. + * 15 에서 20 까지 더 필요한 경험치는 40 x (15+16+17+18+19) = 3,400 점이다. + * + *

상한에 닿아도 {@code totalStudyPoint} 는 계속 쌓인다. 그래서 상한을 올리는 순간 + * 이미 쌓아 둔 잔여 포인트로 레벨이 한 번에 여러 단계 오를 수 있는데, 상한 15 에 닿은 + * 사용자가 아직 없어 이번 변경으로 그런 사용자는 생기지 않는다. + */ + public static final long MAX_TOTAL_STUDY_LEVEL = 20L; + + /** + * 개별 능력치 레벨 상한. + * + *

{@link #MAX_TOTAL_STUDY_LEVEL} 과 반드시 같은 값이어야 해서 상수를 직접 참조한다. + * 총 학습 포인트는 능력치 넷이 받은 포인트의 합이고 총 학습 임계값은 능력치 임계값의 정확히 4배다. + * 그래서 능력치 넷을 레벨 N 까지 채웠을 때 쌓이는 총 학습 포인트 {@code 4 x 5N(N-1)} 과 + * 총 학습 N 레벨에 필요한 누적 {@code 20N(N-1)} 이 항상 같다. 한쪽만 움직이면 이 대응이 깨진다. + * 실제로 능력치 상한 15, 총 학습 상한 20 이던 동안에는 능력치 넷을 다 채워도(4 x 1,050 = 4,200점) + * 총 학습이 15 에서 멈췄다. 지금은 넷을 다 채우면 4 x 1,900 = 7,600 점이라 총 학습도 정확히 20 에 닿는다. + * + *

여기에 상한이 없으면 내부 레벨은 무한정 오르고 응답에서만 잘려, 앱에 보이는 레벨과 + * 실제 레벨이 갈린다. 총 학습과 같은 방식으로 레벨만 멈추고 포인트는 계속 쌓는다. + */ + public static final long MAX_ABILITY_LEVEL = MAX_TOTAL_STUDY_LEVEL; + // 데일리 출석 private Long attendanceLevel; private Long attendancePoint; @@ -35,7 +63,7 @@ public class UserMissionStatus { */ public void gainAttendancePoint(Long value) { this.attendancePoint += value; - while (this.attendancePoint >= getThresholdForLevel(attendanceLevel)) { + while (this.attendanceLevel < MAX_ABILITY_LEVEL && this.attendancePoint >= getThresholdForLevel(attendanceLevel)) { this.attendancePoint -= getThresholdForLevel(attendanceLevel); this.attendanceLevel += 1; } @@ -47,7 +75,7 @@ public void gainAttendancePoint(Long value) { */ public void gainNoteWritePoint(Long value) { this.noteWritePoint += value; - while (this.noteWritePoint >= getThresholdForLevel(noteWriteLevel)) { + while (this.noteWriteLevel < MAX_ABILITY_LEVEL && this.noteWritePoint >= getThresholdForLevel(noteWriteLevel)) { this.noteWritePoint -= getThresholdForLevel(noteWriteLevel); this.noteWriteLevel += 1; } @@ -59,7 +87,7 @@ public void gainNoteWritePoint(Long value) { */ public void gainProblemPracticePoint(Long value) { this.problemPracticePoint += value; - while (this.problemPracticePoint >= getThresholdForLevel(problemPracticeLevel)) { + while (this.problemPracticeLevel < MAX_ABILITY_LEVEL && this.problemPracticePoint >= getThresholdForLevel(problemPracticeLevel)) { this.problemPracticePoint -= getThresholdForLevel(problemPracticeLevel); this.problemPracticeLevel += 1; } @@ -71,7 +99,7 @@ public void gainProblemPracticePoint(Long value) { */ public void gainNotePracticePoint(Long value) { this.notePracticePoint += value; - while (this.notePracticePoint >= getThresholdForLevel(notePracticeLevel)) { + while (this.notePracticeLevel < MAX_ABILITY_LEVEL && this.notePracticePoint >= getThresholdForLevel(notePracticeLevel)) { this.notePracticePoint -= getThresholdForLevel(notePracticeLevel); this.notePracticeLevel += 1; } @@ -86,7 +114,7 @@ private void updateTotalStudyLevel(Long gainedPoints) { this.totalStudyPoint += gainedPoints; // 레벨업 처리 - while (this.totalStudyLevel < 15 && this.totalStudyPoint >= getTotalStudyThresholdForLevel(this.totalStudyLevel)) { + while (this.totalStudyLevel < MAX_TOTAL_STUDY_LEVEL && this.totalStudyPoint >= getTotalStudyThresholdForLevel(this.totalStudyLevel)) { this.totalStudyPoint -= getTotalStudyThresholdForLevel(this.totalStudyLevel); this.totalStudyLevel += 1; } @@ -100,6 +128,7 @@ private void updateTotalStudyLevel(Long gainedPoints) { * - 레벨 1→2: 10 * - 레벨 2→3: 20 * - 레벨 14→15: 140 + * - 레벨 19→20: 190 — 상한. 1 에서 20 까지 누적은 1,900 이다 */ private Long getThresholdForLevel(Long level) { return 10 + (level - 1) * 10; @@ -107,11 +136,12 @@ private Long getThresholdForLevel(Long level) { /** * 총 학습 레벨의 필요 경험치 계산 - * 개별 능력치 필요 경험치 × 4 + * 개별 능력치 필요 경험치 × 4 = 40 × 레벨 * * - 레벨 1→2: 40 (10 × 4) * - 레벨 2→3: 80 (20 × 4) * - 레벨 14→15: 560 (140 × 4) + * - 레벨 19→20: 760 (190 × 4) — 상한. 15 에서 20 까지 합이 3,400 이다 */ private Long getTotalStudyThresholdForLevel(Long level) { return getThresholdForLevel(level) * 4; diff --git a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java index 060f0a5e..ee11bb54 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java @@ -10,7 +10,16 @@ public enum MissionErrorCase implements ErrorCase { MISSION_TYPE_NOT_FOUND(400, 7001, "잘못된 미션 종류입니다."), - USER_NOT_FOUND(404, 7002, "해당하는 유저가 존재하지 않습니다."); + USER_NOT_FOUND(404, 7002, "해당하는 유저가 존재하지 않습니다."), + + // 미션 진행도(mission_progress) 관련. 7001, 7002 에 이어 7010 부터 쓴다. + + /** 남의 진행도를 받으려는 경우에도 이 코드를 준다. 존재 여부를 알려주지 않는다. */ + MISSION_PROGRESS_NOT_FOUND(404, 7010, "미션 진행도를 찾을 수 없습니다."), + + MISSION_NOT_COMPLETED(400, 7011, "아직 완료하지 않은 미션입니다."), + + MISSION_ALREADY_CLAIMED(400, 7012, "이미 보상을 받은 미션입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java new file mode 100644 index 00000000..b04aec9a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java @@ -0,0 +1,25 @@ +package com.aisip.OnO.backend.mission.repository; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MissionDefinitionRepository extends JpaRepository { + + /** 목록 조회용. 정렬은 sortOrder 오름차순이고, 같은 값이면 id 로 흔들림을 없앤다. */ + List findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + /** + * 진행도를 올릴 대상. + * + *

id 오름차순으로 고정한다. 한 번의 행동이 일일/주간 두 미션을 함께 올리는데, + * 동시에 들어온 요청들이 서로 다른 순서로 행을 잠그면 교착이 난다. + * 모든 트랜잭션이 같은 순서로 잠그면 교착 자체가 성립하지 않는다. + */ + List findAllByMetricAndActiveTrueOrderByIdAsc(MissionMetric metric); + + Optional findByCode(String code); +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java index 6a2b211c..89763444 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java @@ -19,6 +19,32 @@ public interface MissionLogRepository extends JpaRepository, M long countByMissionType(MissionType missionType); + /** + * 한 사용자가 그 미션을 남긴 날짜들. 하루에 여러 번이어도 하루로 접힌다. + * + *

훈장 '개근'이 연속 30 일을 세는 데 쓴다. 행을 전부 읽어 자바에서 날짜로 접으면 로그인이 + * 수천 건인 사용자의 행을 통째로 올리게 되므로 날짜로 접는 것까지 DB 에 맡긴다. + * + *

{@code created_at} 은 JPA Auditing 이 JVM 기본 시간대로 채우고, 운영과 테스트 모두 + * {@code -Duser.timezone=Asia/Seoul} 이 걸려 있다. 즉 저장된 값이 이미 KST 벽시계라 + * {@code DATE()} 가 그대로 KST 날짜다. 여기서 시간대를 한 번 더 옮기면 아홉 시간이 밀린다. + * + *

반환 타입을 {@code Object} 로 둔다. {@code FUNCTION('DATE', ...)} 의 자바 타입은 + * Hibernate 가 정하지 않고 JDBC 드라이버가 주는 대로라, 드라이버 버전에 따라 + * {@code java.sql.Date} 일 수도 {@code LocalDate} 일 수도 있다. + * 호출부가 둘 다 받아 넘긴다({@code MissionLogService.toLocalDate} 도 같은 처리를 한다). + */ + @Query(""" + SELECT DISTINCT FUNCTION('DATE', m.createdAt) + FROM MissionLog m + WHERE m.user.id = :userId + AND m.missionType = :missionType + """) + List findDistinctLogDates( + @Param("userId") Long userId, + @Param("missionType") MissionType missionType + ); + long countByMissionTypeAndCreatedAtBetween( MissionType missionType, LocalDateTime startDateTime, diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java index 7b432589..ceec1ddf 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java @@ -6,17 +6,44 @@ import java.util.List; import java.util.Map; +/** + * 중복 방지 판정에 붙는 {@code accruedOnly} 는 "실제로 적립된 행만 셀 것인가"다. + * + *

{@code mission_log} 행은 미션을 받을 수 있는 앱에서 온 요청도 그대로 남긴다. 관리자 통계와 + * 훈장이 이 테이블만 보기 때문이다. 그런데 그 행을 중복 방지에서도 그대로 세면, 같은 계정을 구버전 + * 기기에서도 쓰는 사용자는 구버전 활동이 "이미 적립했다"로 막혀 적립으로도 진행도로도 남지 않는다(#318). + * + *

그래서 자동 적립이 도는 요청은 적립된 행만 세고({@code accruedOnly = true}), + * 진행도만 올리는 요청은 지금처럼 모든 행을 센다({@code accruedOnly = false}). + * 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다. + */ public interface MissionLogRepositoryCustom { boolean alreadyWriteProblemsTodayMoreThan3(Long userId); + boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly); + long countProblemWritesToday(Long userId); + long countProblemWritesToday(Long userId, boolean accruedOnly); + boolean alreadyPracticeProblem(Long problemId); + boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly); + boolean alreadyPracticeNote(Long practiceNoteId); + boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly); + boolean alreadyLogin(Long userId); + boolean alreadyLogin(Long userId, boolean accruedOnly); + + /** + * 오늘 자동 적립으로 잡힌 점수 합. 하루 200점 상한이 이 값을 본다. + * + *

적립이 돌지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. + * 조건을 따로 걸지 않는 이유는 그 0 이 이미 "이 행은 적립되지 않았다"를 뜻하기 때문이다. + */ Long getPointSumToday(Long userId); Map getDailyActiveUsersCount(int days); diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java index 3cce206e..741c74b6 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.user.entity.User; import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.DateExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberExpression; @@ -13,7 +14,6 @@ import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; import java.util.LinkedHashMap; import java.util.Map; @@ -29,17 +29,28 @@ public MissionLogRepositoryImpl(EntityManager entityManager) { @Override public boolean alreadyWriteProblemsTodayMoreThan3(Long userId) { - return countProblemWritesToday(userId) >= 3; + return alreadyWriteProblemsTodayMoreThan3(userId, false); + } + + @Override + public boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly) { + return countProblemWritesToday(userId, accruedOnly) >= 3; } @Override public long countProblemWritesToday(Long userId) { + return countProblemWritesToday(userId, false); + } + + @Override + public long countProblemWritesToday(Long userId, boolean accruedOnly) { Long count = queryFactory .select(missionLog.count()) .from(missionLog) .where(missionLog.missionType.eq(MissionType.PROBLEM_WRITE) .and(missionLog.user.id.eq(userId)) - .and(missionLog.createdAt.between(getStartOfToday(), getEndOfToday())) + .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchOne(); @@ -48,47 +59,79 @@ public long countProblemWritesToday(Long userId) { @Override public boolean alreadyPracticeProblem(Long problemId){ + return alreadyPracticeProblem(problemId, false); + } + + @Override + public boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.PROBLEM_PRACTICE) .and(missionLog.referenceId.eq(problemId)) - .and(missionLog.createdAt.between(getStartOfToday(), getEndOfToday())) + .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyPracticeNote(Long practiceNoteId){ + return alreadyPracticeNote(practiceNoteId, false); + } + + @Override + public boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.NOTE_PRACTICE) .and(missionLog.referenceId.eq(practiceNoteId)) - .and(missionLog.createdAt.between(getStartOfToday(), getEndOfToday())) + .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyLogin(Long userId){ + return alreadyLogin(userId, false); + } + + @Override + public boolean alreadyLogin(Long userId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.USER_LOGIN) .and(missionLog.user.id.eq(userId)) - .and(missionLog.createdAt.between(getStartOfToday(), getEndOfToday())) + .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } + /** + * "실제로 적립된 행만" 조건. {@code accruedOnly} 가 아니면 조건을 붙이지 않는다. + * + *

{@code null} 을 돌려주면 QueryDSL 이 그 항을 통째로 빼므로, 기존 판정의 쿼리가 그대로 남는다. + * 적립된 행은 {@code point} 에 정가가 들어 있고, 적립이 돌지 않은 요청의 행은 0 이다 + * ({@code MissionLog.point}). + * + *

옛 행은 적립 여부와 무관하게 정가가 들어 있어 전부 "적립된 행"으로 잡힌다. + * 덜 주는 쪽이 아니라 지금과 같게 두는 쪽이라 안전하다. + */ + private BooleanExpression accrued(boolean accruedOnly) { + return accruedOnly ? missionLog.point.gt(0L) : null; + } + @Override public Long getPointSumToday(Long userId){ Long result = queryFactory .select(missionLog.point.sum()) .from(missionLog) - .where(missionLog.createdAt.between(getStartOfToday(), getEndOfToday()) + .where(createdToday() .and(missionLog.user.id.eq(userId))) .fetchOne(); @@ -116,7 +159,7 @@ public Map getDailyActiveUsersCount(LocalDate startDate, LocalD .select(createdDate, activeUserCount) .from(missionLog) .where(missionLog.missionType.eq(MissionType.USER_LOGIN) - .and(missionLog.createdAt.between(startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX))) + .and(createdBetweenDates(startDate, endDate)) ) .groupBy(createdDate) .fetch(); @@ -139,25 +182,33 @@ public Map getDailyActiveUsersCount(LocalDate startDate, LocalD @Override public java.util.List getActiveUsersByDate(LocalDate date) { - LocalDateTime startOfDay = date.atStartOfDay(); - LocalDateTime endOfDay = date.atTime(LocalTime.MAX); - return queryFactory .select(missionLog.user) .distinct() .from(missionLog) .where(missionLog.missionType.eq(MissionType.USER_LOGIN) - .and(missionLog.createdAt.between(startOfDay, endOfDay)) + .and(createdBetweenDates(date, date)) ) .fetch(); } - private LocalDateTime getStartOfToday() { - return LocalDate.now().atStartOfDay(); + /** + * "오늘 안에 만들어졌는가" 조건. + * + *

예전에는 {@code between(오늘 00:00, 오늘 23:59:59.999999999)} 를 썼는데, + * MySQL DATETIME(6) 은 마이크로초까지만 저장하므로 끝값이 반올림되어 다음 날 00:00:00 이 되고 + * BETWEEN 은 양끝을 포함하므로 자정 정각에 만들어진 기록이 전날에도 오늘로 잡혔다. + * 그 경우 자정에 로그인한 사용자는 전날 출석이 이미 있는 것으로 판정돼 보상을 잃는다. + * 반열림 구간 {@code [오늘 00:00, 내일 00:00)} 으로 바꿔 경계를 한 번만 세도록 한다. + */ + private BooleanExpression createdToday() { + return createdBetweenDates(LocalDate.now(), LocalDate.now()); } - private LocalDateTime getEndOfToday() { - return LocalDate.now().atTime(LocalTime.MAX); + /** {@code [startDate 00:00, endDate+1일 00:00)} 반열림 구간. */ + private BooleanExpression createdBetweenDates(LocalDate startDate, LocalDate endDate) { + return missionLog.createdAt.goe(startDate.atStartOfDay()) + .and(missionLog.createdAt.lt(endDate.plusDays(1).atStartOfDay())); } private LocalDate toLocalDate(Object value) { diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java new file mode 100644 index 00000000..1dae49e6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -0,0 +1,176 @@ +package com.aisip.OnO.backend.mission.repository; + +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface MissionProgressRepository extends JpaRepository { + + List findAllByUserIdAndPeriodKeyIn(Long userId, Collection periodKeys); + + Optional findByUserIdAndMissionIdAndPeriodKey(Long userId, Long missionId, String periodKey); + + /** + * 지난 기간에 완료했지만 아직 받지 않은 진행도. + * + *

기간 키로만 조회하면 일요일 밤에 주간 미션을 끝내고 받지 않은 사용자는 월요일부터 + * 그 보상을 받을 방법이 없어진다. 복습 30회를 채운 사용자가 보상을 못 받는 것은 그대로 CS 다. + * + *

{@code completedAfter} 로 상한을 둔다. 상한이 없으면 몇 년치 미수령이 매 조회마다 딸려 온다. + */ + @Query(""" + SELECT p FROM MissionProgress p + WHERE p.userId = :userId + AND p.completedAt IS NOT NULL + AND p.claimedAt IS NULL + AND p.completedAt >= :completedAfter + AND p.periodKey NOT IN :currentPeriodKeys + """) + List findUnclaimedFromPastPeriods( + @Param("userId") Long userId, + @Param("currentPeriodKeys") Collection currentPeriodKeys, + @Param("completedAfter") LocalDateTime completedAfter + ); + + /** + * 보상을 받은 기록. 받은 시각 내림차순이고, 커서보다 뒤엣것만 준다. + * + *

커서는 진행도 id 하나뿐인데 정렬은 {@code claimed_at} 이라 {@code id < :cursor} 로는 안 된다. + * 나중에 만들어진 행이 먼저 받아질 수 있어 id 순서와 받은 순서가 다르기 때문이다. + * 그래서 커서 행의 받은 시각을 함께 넘겨 (받은 시각, id) 두 값으로 자른다. + * 시각이 같은 행이 있어도 id 가 갈라 주므로 경계에서 빠지거나 겹치지 않는다. + */ + @Query(""" + SELECT p FROM MissionProgress p + WHERE p.userId = :userId + AND p.claimedAt IS NOT NULL + AND (:cursorClaimedAt IS NULL + OR p.claimedAt < :cursorClaimedAt + OR (p.claimedAt = :cursorClaimedAt AND p.id < :cursorId)) + ORDER BY p.claimedAt DESC, p.id DESC + """) + List findClaimedPage( + @Param("userId") Long userId, + @Param("cursorClaimedAt") LocalDateTime cursorClaimedAt, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + /** 커서 행. 남의 id 를 커서로 넘겨도 자기 것만 잡히게 소유권을 조건에 건다. */ + Optional findByIdAndUserId(Long id, Long userId); + + long countByUserIdAndClaimedAtIsNotNull(Long userId); + + /** + * 지금까지 받은 XP 합계. + * + *

목록에 보이는 값과 합계가 어긋나면 안 되므로 여기서도 스냅샷을 먼저 본다. + * 스냅샷이 없는 옛 행만 현재 정의로 폴백한다. 정의는 연관관계를 걸지 않아 식별자로 잇는다. + */ + @Query(value = """ + SELECT COALESCE(SUM(COALESCE(p.reward_value_snapshot, d.reward_value)), 0) + FROM mission_progress p + JOIN mission_definition d ON d.id = p.mission_id + WHERE p.user_id = :userId + AND p.claimed_at IS NOT NULL + AND COALESCE(p.reward_type_snapshot, d.reward_type) = 'XP' + """, nativeQuery = true) + long sumClaimedXp(@Param("userId") Long userId); + + /** + * 진행도를 한 문장으로 만들거나 올린다. + * + *

"없으면 만들고 있으면 올린다"를 애플리케이션에서 갈라 쓰면 두 가지가 무너진다. + * 첫째, 같은 사용자의 요청이 겹치면 둘 다 "없다"를 읽고 INSERT 해 유니크 제약에 걸린다. + * JPA 에서 제약 위반은 트랜잭션을 rollback-only 로 만들기 때문에 잡아서 UPDATE 로 넘어갈 수도 없다. + * 둘째, 읽고 나서 쓰는 증가는 증가분을 잃는다. + * + *

{@code INSERT ... ON DUPLICATE KEY UPDATE} 는 둘 다 한 번에 해결한다. 중복 키를 만나면 + * MySQL 이 그 행에 배타 잠금을 걸고 UPDATE 로 바꿔 실행하므로, 예외 경로도 없고 + * 잠금 승격으로 인한 교착도 없다. + * + *

증가분은 {@code LEAST} 로 {@code target_snapshot} 에서 멈춘다. 목표를 넘겨도 + * 화면에 "4 / 3" 이 보이면 안 된다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, created_at, updated_at) + VALUES + (:userId, :missionId, :periodKey, LEAST(:amount, :target), :target, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + current_value = LEAST(current_value + :amount, target_snapshot), + updated_at = NOW(6) + """, nativeQuery = true) + int increaseValue( + @Param("userId") Long userId, + @Param("missionId") Long missionId, + @Param("periodKey") String periodKey, + @Param("amount") int amount, + @Param("target") int target + ); + + /** + * 완료 도장을 한 번만 찍는다. + * + *

{@code completed_at IS NULL} 을 조건에 넣어, 목표를 넘긴 뒤 또 올려도 완료 시각이 + * 뒤로 밀리지 않는다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE mission_progress + SET completed_at = NOW(6), + updated_at = NOW(6) + WHERE user_id = :userId + AND mission_id = :missionId + AND period_key = :periodKey + AND completed_at IS NULL + AND current_value >= target_snapshot + """, nativeQuery = true) + int markCompleted( + @Param("userId") Long userId, + @Param("missionId") Long missionId, + @Param("periodKey") String periodKey + ); + + /** + * 보상 수령 도장. 갱신된 행이 1일 때만 지급한다. + * + *

조회로 "아직 안 받았다"를 확인하고 지급하면, 버튼을 두 번 빠르게 누른 두 요청이 + * 모두 통과해 XP 가 두 번 들어간다. {@code claimed_at IS NULL} 을 UPDATE 조건에 넣으면 + * 뒤에 온 요청은 0행을 갱신하고 거절된다. + * + *

{@code user_id} 조건은 호출부의 소유권 검사와 중복이지만 일부러 남겨 둔다. + * 검사를 서비스에만 두면 이 메서드를 다른 곳에서 부르는 순간 소유권 검증이 통째로 빠진다. + * "모든 데이터 접근은 userId 기준"이라는 불변식은 쿼리 자체에 박혀 있어야 한다. + * + *

보상 스냅샷도 같은 문장에서 박는다. 엔티티를 고쳐 더티 체킹에 맡기면 안 된다. + * 이 UPDATE 는 네이티브라 영속성 컨텍스트의 진행도 엔티티는 여전히 {@code claimedAt} 이 null 인데, + * 그 상태로 엔티티가 더러워지면 커밋 시점의 UPDATE 가 모든 컬럼을 쓰면서 방금 찍은 수령 시각을 지운다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE mission_progress + SET claimed_at = NOW(6), + reward_type_snapshot = :rewardType, + reward_value_snapshot = :rewardValue, + updated_at = NOW(6) + WHERE id = :progressId + AND user_id = :userId + AND claimed_at IS NULL + """, nativeQuery = true) + int markClaimed( + @Param("progressId") Long progressId, + @Param("userId") Long userId, + @Param("rewardType") String rewardType, + @Param("rewardValue") int rewardValue + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java b/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java new file mode 100644 index 00000000..446df672 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java @@ -0,0 +1,85 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.web.AppVersion; +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 지금 요청에서 자동 적립이 도는가를 정하는 한 곳. + * + *

XP 가 들어오는 길은 둘이다. 행동만으로 들어가는 자동 적립({@code MissionLogService})과, + * 미션 진행도를 채운 뒤 앱에서 받는 미션 보상({@code MissionService.claim})이다. + * 한 행동에는 둘 중 정확히 하나만 열려야 한다. 자동 적립이 도는 요청에서 진행도까지 완료되면 + * 그 사용자가 새 앱으로 미션 화면을 열었을 때 같은 행동으로 XP 를 한 번 더 받는다. + * + *

그래서 자동 적립({@code MissionLogService.addPointToUser})과 진행도 증가 + * ({@link MissionProgressUpdater#increase})가 이 판정 하나를 같이 본다. 두 곳이 판정을 따로 들고 있으면 + * 한쪽만 고쳐졌을 때 다시 이중 지급이나 무지급이 생긴다. + * + *

HTTP 요청이 없는 자리에서 불리면 자동 적립이 도는 쪽이다. 헤더가 없는 것과 같게 본다. + * 지금 두 곳을 부르는 경로는 전부 컨트롤러에서 시작하는 HTTP 요청 안이다(Quartz 잡, RabbitMQ 소비자, + * {@code @TransactionalEventListener}, {@code @Async} 어디에서도 부르지 않는다). 나중에 그런 경로가 생겨도 + * 두 곳이 같은 답을 받으므로 둘 중 하나만 도는 것은 유지된다. + */ +@Component +@RequiredArgsConstructor +public class LegacyAccrualPolicy { + + private final AppVersionResolver appVersionResolver; + + /** + * 행동만으로 경험치를 주던 예전 적립을 계속 쓸지. + * + *

비상 스위치다. 꺼지면 앱 버전을 보지 않고 전부 끈다. 자동 적립 자체에 문제가 생겼을 때 + * 배포 없이 통째로 멈출 자리가 하나 있어야 한다. 꺼지면 진행도는 앱 버전과 무관하게 오른다. + * + *

기본값이 켜짐이라 설정을 건드리지 않으면 버전 판정만 돈다. + */ + @Value("${ono.mission.legacy-accrual.enabled:true}") + private boolean legacyAccrualEnabled; + + /** + * 미션을 받을 수 있는 첫 앱 버전. 이 버전 이상에서 온 요청은 자동 적립을 끄고 진행도만 올린다. + * + *

기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 앱은 + * 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 어디까지를 새 앱으로 볼지만 + * 가른다. 스토어에 나간 4.0.0 빌드에는 미션 화면도 헤더도 없고, 프론트에서 헤더를 붙인 커밋은 + * 미션 조회·받기 통신 계층이 들어간 뒤에 들어갔다. 그래서 헤더를 보내는 빌드는 전부 미션을 받을 수 있고, + * 기준을 더 높이면 그 빌드에서 이중 지급이 계속된다. + * + *

설정값으로 둔 이유는 미션 화면이 빠지거나 받기가 고장 난 버전이 뒤늦게 드러났을 때 + * 배포 없이 기준을 올려 되돌리기 위해서다. + */ + @Value("${ono.mission.mission-capable-version:4.0.0}") + private String missionCapableVersion; + + /** + * 이번 요청에서 자동 적립이 도는가. {@code true} 면 자동 적립만, {@code false} 면 미션 진행도만 연다. + */ + public boolean accruesForCurrentRequest() { + // 비상 스위치가 먼저다. 꺼져 있으면 버전을 아예 보지 않는다. + if (!legacyAccrualEnabled) { + return false; + } + return !missionCapableRequest(); + } + + /** + * 이번 요청이 미션을 받을 수 있는 앱에서 왔는가. + * + *

모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 구버전으로 잘못 보면 자동 적립으로 받고 진행도가 안 오를 뿐이지만, + * 반대로 틀리면 구버전 사용자는 받을 방법이 없어 XP 가 통째로 멈춘다. 덜 주는 쪽이 더 위험하다. + * + *

기준값이 읽히지 않을 때도 같은 이유로 아무도 새 앱으로 보지 않는다. 설정 오타 하나가 + * 전체 사용자의 XP 유입을 끊는 것보다 새 앱 사용자가 자동 적립으로 받는 편이 낫다. + */ + private boolean missionCapableRequest() { + return AppVersion.parse(missionCapableVersion) + .flatMap(threshold -> appVersionResolver.resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 07443308..1a6f8dcc 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.exception.MissionErrorCase; import com.aisip.OnO.backend.mission.repository.MissionLogRepository; @@ -22,7 +23,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -30,6 +30,40 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 사용자의 행동 기록({@code mission_log})을 남긴다. + * + *

자동 적립은 요청이 온 앱 버전에 따라 갈린다. 미션을 받을 수 있는 앱이면 끄고, 아니면 켠다. + * 판정은 {@link LegacyAccrualPolicy} 한 곳에 있고, 미션 진행도 증가도 같은 판정을 본다. + * + *

가르는 이유는 켜져 있으면 같은 XP 가 두 경로로 들어오기 때문이다. 오답노트를 하나 쓰면 + * 자동 적립 +10 이 조용히 들어가고 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, + * 화면의 {@code +10 XP} 와 실제 증가량 20 이 어긋난다. 하루 200점 상한도 자동 적립에만 걸려 + * 같은 이름의 XP 가 출처에 따라 다른 규칙으로 움직인다. + * + *

그렇다고 플래그 하나로 전체를 끌 수는 없다. 적립과 미션 진행도는 서버가 올리지만 + * 받는 것은 앱이 한다. 미션 화면이 없는 구버전 앱은 {@code claim} 을 부를 방법이 없어, + * 통째로 끄면 구버전 사용자는 공부를 해도 XP 가 한 점도 안 쌓인다. 진행도만 쌓이고 레벨은 멈춘다. + * 스토어 심사와 강제 업데이트 없이는 구버전이 한동안 남으므로, 요청마다 앱 버전을 보고 가른다. + * + *

어느 쪽이든 기록은 그대로 남는다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. + * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정도 설정과 무관하게 돈다. + * 자동 적립이 도는 요청에서는 미션 진행도가 오르지 않는다. 같은 행동으로 적립과 미션 보상을 + * 둘 다 받지 않게 하기 위해서다. 진행도를 막는 것은 {@link MissionProgressUpdater} 가 한다. + * + *

중복 방지는 요청이 어느 경로인가에 따라 보는 범위가 다르다. 자동 적립이 도는 요청은 + * 실제로 적립된 행만 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다 + * ({@code MissionLogRepositoryCustom} 의 {@code accruedOnly}). + * + *

가르는 이유는 같은 계정을 두 기기에서 쓰는 사용자 때문이다(#318). 신버전 요청이 남긴 행을 + * 구버전 요청의 중복 방지가 그대로 세면, 그 활동은 적립으로도(행에 막혀서) 진행도로도 + * (구버전이라 막혀서) 남지 않아 어느 쪽으로도 XP 가 들어오지 않는다. 반대로 양쪽 판정을 함께 풀면 + * 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오르므로, 푸는 것은 적립 쪽 하나뿐이다. + * + *

대신 두 기기를 번갈아 쓰면 같은 대상에 대해 한 번은 적립, 한 번은 진행도가 따로 잡힌다. + * 기기마다 따로 한 활동이라 요청 하나가 두 경로로 받는 것은 아니다. 활동을 통째로 잃는 것보다 낫다고 보고 + * 받아들인 값이다. + */ @Service @RequiredArgsConstructor @Transactional @@ -41,34 +75,38 @@ public class MissionLogService { private final PracticeNoteRepository practiceNoteRepository; - private static final Long DAILY_MISSION_POINT_LIMIT = 200L; - - public Long registerMissionLog(@NotNull MissionRegisterDto missionRegisterDto) { - - Long userId = missionRegisterDto.userId(); - boolean canNotRegister = true; - canNotRegister = switch (missionRegisterDto.missionType()) { - case USER_LOGIN -> missionLogRepository.alreadyLogin(userId); - case PROBLEM_WRITE -> missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId); - case PROBLEM_PRACTICE -> missionLogRepository.alreadyPracticeProblem(missionRegisterDto.referenceId()); - case NOTE_PRACTICE -> missionLogRepository.alreadyPracticeNote(missionRegisterDto.referenceId()); - default -> throw new ApplicationException(MissionErrorCase.MISSION_TYPE_NOT_FOUND); - }; + private final MissionProgressUpdater missionProgressUpdater; - if(!canNotRegister) { - User user = userRepository.findById(userId).orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); + private final LegacyAccrualPolicy legacyAccrualPolicy; - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); - missionLogRepository.save(missionLog); - - addPointToUser(user, missionLog); - } + private static final Long DAILY_MISSION_POINT_LIMIT = 200L; - return 0L; + /** + * 미션 적립 전에 사용자 행을 먼저 잠근다. + * + *

두 가지를 동시에 막는다. + * + *

첫째, 교착. mission_log 는 user 를 참조하므로 INSERT 시 InnoDB 가 부모 행에 공유 잠금을 건다. + * 그 뒤 적립 포인트를 반영하려고 같은 행을 UPDATE 하면 공유 잠금을 배타 잠금으로 승격해야 하는데, + * 같은 사용자의 요청이 동시에 들어오면 서로 상대의 공유 잠금 때문에 승격하지 못해 교착이 난다. + * 실제로 같은 사용자가 미션을 동시에 8번 적립하면 + * {@code Deadlock found when trying to get lock} 이 그대로 500 으로 나갔다. + * 처음부터 배타 잠금을 잡으면 승격 자체가 없어 교착이 생기지 않는다. + * (자동 적립을 걷어내면서 사용자 행 UPDATE 가 없어져 이 승격 경로는 사라졌다. 기록으로 남겨 둔다.) + * + *

둘째, 중복 적립. "오늘 이미 했는가"를 확인한 뒤 적립하는 check-then-act 구조라, + * 잠금이 없으면 동시 요청이 모두 "아직 안 했다"를 읽고 각자 적립한다. + * 사용자 단위로 직렬화하면 뒤에 온 요청은 앞선 적립을 보고 건너뛴다. + */ + private User lockUser(Long userId) { + return userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); } public void registerLoginMission(Long userId) { - boolean alreadyLogin = missionLogRepository.alreadyLogin(userId); + User user = lockUser(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyLogin = missionLogRepository.alreadyLogin(userId, accrues); if(!alreadyLogin) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -77,16 +115,22 @@ public void registerLoginMission(Long userId) { .missionType(MissionType.USER_LOGIN) .build(); - User user = userRepository.findById(userId).orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); + + // 출석 미션 진행도. 기존 적립 규칙은 그대로 두고, "오늘 첫 로그인" 판정만 그대로 빌려 쓴다. + // 이 분기 밖에서 올리면 앱을 열 때마다 주간 출석 미션이 하루에 5까지 차 버린다. + missionProgressUpdater.increase(userId, MissionMetric.LOGIN_DAY); } } public void registerProblemWriteMission(Long userId) { - boolean alreadyWriteMoreThanThreeProblems = missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId); + User user = lockUser(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyWriteMoreThanThreeProblems = + missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId, accrues); if(!alreadyWriteMoreThanThreeProblems) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -95,8 +139,7 @@ public void registerProblemWriteMission(Long userId) { .missionType(MissionType.PROBLEM_WRITE) .build(); - User user = userRepository.findById(userId).orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -104,27 +147,28 @@ public void registerProblemWriteMission(Long userId) { } public void registerProblemWriteMissionBatch(Long userId, int count) { - long todayCount = missionLogRepository.countProblemWritesToday(userId); + User user = lockUser(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + long todayCount = missionLogRepository.countProblemWritesToday(userId, accrues); int toCreate = (int) Math.min(count, Math.max(0, 3 - todayCount)); if (toCreate == 0) return; - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); - MissionRegisterDto dto = MissionRegisterDto.builder() .userId(userId) .missionType(MissionType.PROBLEM_WRITE) .build(); for (int i = 0; i < toCreate; i++) { - MissionLog log = MissionLog.from(dto, user); + MissionLog log = MissionLog.from(dto, user, accrues); missionLogRepository.save(log); addPointToUser(user, log); } } public void registerProblemPracticeMission(Long userId, Long problemId) { - boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId); + User user = lockUser(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId, accrues); if(!alreadyPracticeProblem) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -134,8 +178,7 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { .referenceId(problemId) .build(); - User user = userRepository.findById(userId).orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -143,7 +186,9 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { } public void registerNotePracticeMission(Long userId, Long practiceNoteId) { - boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId); + User user = lockUser(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId, accrues); if(!alreadyPracticeNote) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -153,21 +198,39 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { .referenceId(practiceNoteId) .build(); - User user = userRepository.findById(userId).orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); + + // 세트 완료 미션 진행도. 출석과 같은 이유로 기존 중복 방지 가드 안에 둔다. + // 밖에서 올리면 같은 세트에 완료 요청을 세 번 보내는 것만으로 주간 세트 미션이 채워진다. + missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); } } + /** + * 행동 자체에 대한 자동 적립. + * + *

적립하지 않기로 하면 아무것도 하지 않는다. 이 메서드만 비는 것이지 호출부의 기록 저장과 + * 중복 방지 판정은 그대로 돈다. 진행도 증가는 반대로 적립하는 요청에서만 막힌다. + * + *

하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 + * 따로 지급하고 상한을 타지 않는다. 상한이 보는 {@code getPointSumToday} 는 적립이 돈 행만 센다. + * 적립하지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. + */ private Long addPointToUser(User user, MissionLog missionLog) { + // 비상 스위치가 꺼졌거나 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. + if (!legacyAccrualPolicy.accruesForCurrentRequest()) { + return 0L; + } + Long pointToday = missionLogRepository.getPointSumToday(user.getId()); - if(pointToday <= DAILY_MISSION_POINT_LIMIT) { - Long point = getMin(missionLog.getPoint(), DAILY_MISSION_POINT_LIMIT - pointToday); + if (pointToday <= DAILY_MISSION_POINT_LIMIT) { + Long point = Math.min(missionLog.getPoint(), DAILY_MISSION_POINT_LIMIT - pointToday); // 미션 타입에 따라 적절한 능력치에 경험치 적용 - switch(missionLog.getMissionType().getAbilityType()) { + switch (missionLog.getMissionType().getAbilityType()) { case ATTENDANCE -> user.getUserMissionStatus().gainAttendancePoint(point); case NOTE_WRITE -> user.getUserMissionStatus().gainNoteWritePoint(point); case PROBLEM_PRACTICE -> user.getUserMissionStatus().gainProblemPracticePoint(point); @@ -175,13 +238,8 @@ private Long addPointToUser(User user, MissionLog missionLog) { } return point; - } else { - return 0L; } - } - - private Long getMin(Long p1, Long p2) { - return p1 > p2 ? p2 : p1; + return 0L; } @Transactional(readOnly = true) diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java new file mode 100644 index 00000000..9d860620 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java @@ -0,0 +1,62 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.IsoFields; + +/** + * 진행도 행을 가르는 기간 키. + * + *

자정에 어제 진행도를 지우는 배치를 두지 않는다. 오늘 키로 조회하면 어제 것은 애초에 안 잡히기 때문이다. + * Quartz 가 {@code isClustered: false} 인 채로 blue-green 배포를 하고 있어 배포 구간에 스케줄 작업이 + * 양쪽에서 돌 위험이 있는데, 리셋 배치를 만들지 않으면 그 위험이 통째로 사라진다. + * + *

하루의 경계는 KST 다. problem·studyroom 등 다른 도메인이 이미 KST 로 하루를 가르고 있어 + * JVM 기본 시간대를 쓰면 배포 환경에 따라 "오늘"이 하루 어긋난다. + */ +public final class MissionPeriodKey { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private MissionPeriodKey() { + } + + public static LocalDate today() { + return LocalDate.now(KST); + } + + /** + * 지금 시각(KST). + * + *

인자 없는 {@code LocalDateTime.now()} 는 JVM 기본 시간대를 쓴다. 서비스 기준은 KST 이고, + * 배포 환경의 시간대에 따라 기준이 흔들리면 안 된다. + */ + public static LocalDateTime now() { + return LocalDateTime.now(KST); + } + + public static String of(MissionCategory category, LocalDate date) { + return category == MissionCategory.WEEKLY ? weekly(date) : daily(date); + } + + /** {@code yyyy-MM-dd}. */ + public static String daily(LocalDate date) { + return date.toString(); + } + + /** + * {@code yyyy-'W'ww} (ISO 8601 주차, 월요일 시작). + * + *

연도는 달력 연도가 아니라 주 기반 연도를 쓴다. 2019-12-31 은 달력으로는 2019년이지만 + * ISO 로는 2020년 1주차다. 달력 연도로 키를 만들면 그 주가 {@code 2019-W01} 과 {@code 2020-W01} 로 + * 쪼개져, 연말연시에 주간 미션이 두 번 초기화된다. + */ + public static String weekly(LocalDate date) { + int weekBasedYear = date.get(IsoFields.WEEK_BASED_YEAR); + int weekOfYear = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return String.format("%d-W%02d", weekBasedYear, weekOfYear); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java new file mode 100644 index 00000000..3bdc4d2c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java @@ -0,0 +1,92 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +/** + * 다른 도메인이 부르는 진행도 증가 진입점. + * + *

호출자의 트랜잭션 안에서 돈다. 한때 커밋 후 실행({@code AFTER_COMMIT}) + 새 트랜잭션 + * ({@code REQUIRES_NEW}) 으로 떼어냈다가 되돌렸다. 되돌린 이유와 그 대가를 남겨 둔다. + * + *

왜 떼어냈었나. 진행도 갱신이 잠금 대기 시간을 넘기거나 교착의 희생자가 되면 + * 사용자가 방금 저장한 오답노트까지 함께 롤백되기 때문이다. 그 걱정 자체는 지금도 유효하다. + * + *

왜 되돌렸나. 떼어내면 커밋 직후 잠깐 요청 하나가 커넥션을 두 개 잡는다. + * 바깥 트랜잭션의 커넥션은 {@code afterCommit} 콜백이 끝나고 {@code cleanupAfterCompletion} 에서야 + * 반납되는데, 그 콜백 안에서 {@code REQUIRES_NEW} 가 새 커넥션을 또 얻기 때문이다. + * 실측으로 동시 요청 8건에 커넥션 16개가 필요했다(풀 9면 전부 멈추고 16이면 통과). 정확히 두 배다. + * 운영 Hikari 풀은 기본값 10이라 미션을 건드리는 요청 다섯 건만 겹쳐도 서비스 전체가 멈춘다. + * + *

무엇을 받아들였나. 두 위험의 크기가 다르다. 롤백 전파는 같은 사용자가 두 요청을 동시에 보내고 + * 같은 진행도 행에서 잠금을 오래 기다려야 나는 일이라 드물고, 터져도 요청 하나가 실패한다. + * 커넥션 고갈은 사용자가 누구든 다섯 건만 겹치면 나고, 터지면 서비스 전체가 멈춘다. + * 드문 요청 실패가 전체 정지보다 낫다. 그래서 진행도 갱신이 실패하면 본 작업도 함께 롤백된다는 것을 + * 알면서 이 방식을 고른 것이다. 이 절충을 바꾸려면 커넥션을 두 배로 쓰지 않는 방법 + * (예: 별도 실행기로 요청 스레드에서 떼어내기)을 먼저 마련해야 한다. + * + *

덧붙여 이 방식은 "본 작업이 롤백되면 진행도도 남지 않는다"를 그대로 지킨다. + * 하지도 않은 행동으로 보상을 받는 일은 없다. + * + *

자동 적립이 도는 요청에서는 올리지 않는다. 로그인, 오답노트 등록, 복습, 세트 완료는 + * 자동 적립도 함께 부른다. 헤더를 안 보내는 구버전 앱에서 한 활동이 자동 적립 XP 를 받으면서 진행도까지 + * 완료되면, 같은 사용자가 새 앱으로 미션 화면을 열었을 때 그 미션을 받아 XP 를 한 번 더 받는다. + * 판정은 자동 적립과 같은 {@link LegacyAccrualPolicy} 를 본다. 호출부마다 막지 않고 여기 한 곳에서 막는 이유는 + * 호출부가 여러 도메인에 흩어져 있어 하나라도 빠지면 이중 지급이 다시 열리기 때문이다. + * 자동 적립을 부르지 않는 항목({@link MissionMetric#MOOD_LOGGED})은 버전과 무관하게 오른다. + * + *

증가는 리포지토리의 upsert 한 문장으로만 한다. 여기서 조회한 뒤 값을 계산해 저장하면 + * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. + */ +@Component +@RequiredArgsConstructor +public class MissionProgressUpdater { + + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + private final LegacyAccrualPolicy legacyAccrualPolicy; + + /** + * 애노테이션이 여기에도 붙어 있어야 한다. 이 오버로드는 같은 빈의 3인자 메서드를 직접 부르는데, + * 자기 호출은 프록시를 타지 않아 저쪽의 {@code @Transactional} 이 적용되지 않는다. + * 호출자에게 트랜잭션이 없으면 그대로 트랜잭션 없이 실행돼 flush 에서 터진다. + */ + @Transactional + public void increase(Long userId, MissionMetric metric) { + increase(userId, metric, 1); + } + + /** + * @param amount 한 번에 오를 양. 오답노트를 여러 장 등록하면 장수만큼 오른다. + */ + @Transactional + public void increase(Long userId, MissionMetric metric, int amount) { + if (userId == null || metric == null || amount <= 0) { + return; + } + + // 이 요청은 자동 적립으로 이미 XP 를 받는다. 진행도까지 채우면 나중에 미션으로 한 번 더 받는다. + if (metric.isAlsoAccruedByLegacyPath() && legacyAccrualPolicy.accruesForCurrentRequest()) { + return; + } + + LocalDate today = MissionPeriodKey.today(); + List definitions = + missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); + + for (MissionDefinition definition : definitions) { + String periodKey = MissionPeriodKey.of(definition.getCategory(), today); + missionProgressRepository.increaseValue( + userId, definition.getId(), periodKey, amount, definition.getTarget()); + missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java new file mode 100644 index 00000000..80436d34 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -0,0 +1,116 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 미션 보상 지급. + * + *

미션 보상은 하루 200점 상한을 타지 않는다. 상한은 {@code MissionLogService} 의 자동 적립에만 + * 있는 규칙이고, 미션 보상은 그 위에 얹는 별도 경로다. 상한 역할은 미션 설계가 대신한다. + * 일일 6종을 다 받아도 75, 주간 4종을 다 받아도 360 이다. + * + *

자동 적립과 이 경로는 한 행동에 둘 중 하나만 열린다. 두 경로가 같은 행동으로 함께 돌면 + * 같은 XP 를 두 번 받는 이중 지급이다. 어느 쪽이 열릴지는 {@link LegacyAccrualPolicy} 가 요청마다 정한다. + * 자동 적립이 도는 요청에서는 진행도가 오르지 않아 받을 미션이 생기지 않고, 미션을 받을 수 있는 앱의 + * 요청이나 비상 스위치({@code ono.mission.legacy-accrual.enabled})가 꺼진 동안에는 여기가 XP 가 들어오는 + * 유일한 경로다. + * + *

능력치 경험치 반영은 엔티티를 읽어 고치는 방식이라 같은 사용자의 동시 지급에 취약하다. + * 기존 적립 경로와 같은 이유로 사용자 행을 배타 잠금으로 먼저 잡아 직렬화한다. + * 잠금을 나중에 잡으면 잠금 승격 과정에서 교착이 난다. + */ +@Component +@RequiredArgsConstructor +public class MissionRewardGranter { + + private final UserRepository userRepository; + + /** + * 지급 전에 사용자 행을 먼저 잠근다. + * + *

잠금 순서를 사용자 → 진행도 하나로 통일하기 위해서다. 기존 적립 경로 + * ({@code MissionLogService.registerLoginMission})는 사용자 행을 잠근 상태에서 진행도를 올리는데, + * 받기가 진행도를 먼저 잠그고 사용자를 나중에 잠그면 두 요청이 서로의 잠금을 기다려 교착이 난다. + */ + @Transactional + public void lockUser(Long userId) { + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); + } + + @Transactional + public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType rewardType, int rewardValue) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); + + UserMissionStatus status = user.getUserMissionStatus(); + long levelBefore = status.getTotalStudyLevel(); + + // 어느 능력치에 넣었는지와 그 능력치의 전후 레벨을 함께 돌려준다. 치장 해금이 + // 총 학습 레벨만이 아니라 능력치 레벨로도 열리기 때문에, 호출부가 이 값을 모르면 + // 능력치가 올라 열린 것을 수령 응답에서 통째로 빠뜨린다. + AbilityType ability = metric == null ? null : metric.getAbilityType(); + long abilityLevelBefore = abilityLevelOf(status, ability); + + if (rewardType == MissionRewardType.XP && rewardValue > 0 && ability != null) { + long point = rewardValue; + switch (ability) { + case ATTENDANCE -> status.gainAttendancePoint(point); + case NOTE_WRITE -> status.gainNoteWritePoint(point); + case PROBLEM_PRACTICE -> status.gainProblemPracticePoint(point); + case NOTE_PRACTICE -> status.gainNotePracticePoint(point); + } + } + + long levelAfter = status.getTotalStudyLevel(); + return new GrantResult(levelBefore, levelAfter, levelAfter > levelBefore, + ability, abilityLevelBefore, abilityLevelOf(status, ability)); + } + + /** 능력치가 없는 보상이면 0 이다. 전후가 같으니 해금 구간이 비게 된다. */ + private long abilityLevelOf(UserMissionStatus status, AbilityType ability) { + if (ability == null) { + return 0L; + } + return switch (ability) { + case ATTENDANCE -> status.getAttendanceLevel(); + case NOTE_WRITE -> status.getNoteWriteLevel(); + case PROBLEM_PRACTICE -> status.getProblemPracticeLevel(); + case NOTE_PRACTICE -> status.getNotePracticeLevel(); + }; + } + + /** + * 지급 결과. + * + *

{@code levelBefore} 를 함께 돌려주는 이유는 호출부가 "이번에 무엇이 열렸는지" 를 + * 계산해야 하기 때문이다. 레벨이 한 번에 여러 단계 오를 수 있어 {@code leveledUp} 만으로는 + * 어디서 어디까지 올랐는지 알 수 없다. + * + *

{@code abilityType} 과 그 능력치의 전후 레벨도 함께 준다. 치장 해금이 능력치별로도 + * 열리는데, 어느 능력치에 얼마가 들어갔는지는 여기서만 알 수 있다. + * XP 가 아닌 보상이면 {@code abilityType} 이 null 이고 전후 레벨은 둘 다 0 이라 구간이 비어 있다. + * + *

여기서 해금 아이템까지 조회하지 않는다. 이 클래스는 사용자 행을 배타 잠금으로 잡은 + * 구간이라, 잠금을 들고 하는 일을 늘리면 같은 사용자의 다른 요청이 그만큼 더 기다린다. + */ + public record GrantResult( + long levelBefore, + Long totalStudyLevel, + boolean leveledUp, + AbilityType abilityType, + long abilityLevelBefore, + long abilityLevelAfter + ) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java new file mode 100644 index 00000000..684d047f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -0,0 +1,252 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.service.CosmeticService; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryItemDto; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionResponseDto; +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 미션 조회와 보상 받기. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MissionService { + + /** + * 지난 기간 미수령 보상을 얼마나 거슬러 보여줄지. + * + *

상한이 없으면 오래 쓴 사용자일수록 매 조회에 몇 년치 미수령이 딸려 온다. + * 한 달이면 "지난 주에 받는 걸 깜빡했다"는 실제 상황은 모두 덮는다. + */ + private static final int EXPIRED_LOOKBACK_DAYS = 30; + + /** 기록 조회 한 페이지 최대 건수. 요청이 아무리 커도 여기서 자른다. */ + private static final int MAX_HISTORY_SIZE = 50; + + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + private final MissionRewardGranter missionRewardGranter; + private final CosmeticService cosmeticService; + + /** + * 활성 미션 전부와 이번 기간의 진행도. + * + *

아직 손대지 않은 미션도 {@code current: 0} 으로 내려간다. 조회는 읽기 전용이라 + * 여기서 진행도 행을 만들지 않는다. 화면에 미션 목록만 띄운 사용자 수만큼 빈 행이 생기는 것을 피한다. + */ + public MissionListResponseDto getMissions(Long userId) { + LocalDate today = MissionPeriodKey.today(); + String dailyKey = MissionPeriodKey.daily(today); + String weeklyKey = MissionPeriodKey.weekly(today); + + List definitions = missionDefinitionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + // 소유권: 조회 대상은 요청한 사용자의 진행도로만 한정한다. + Map progressByKey = new HashMap<>(); + missionProgressRepository.findAllByUserIdAndPeriodKeyIn(userId, List.of(dailyKey, weeklyKey)) + .forEach(progress -> progressByKey.put(cacheKey(progress.getMissionId(), progress.getPeriodKey()), progress)); + + List daily = new ArrayList<>(); + List weekly = new ArrayList<>(); + for (MissionDefinition definition : definitions) { + boolean isWeekly = definition.getCategory() == MissionCategory.WEEKLY; + String periodKey = isWeekly ? weeklyKey : dailyKey; + MissionResponseDto dto = MissionResponseDto.from( + definition, progressByKey.get(cacheKey(definition.getId(), periodKey)), periodKey); + if (isWeekly) { + weekly.add(dto); + } else { + daily.add(dto); + } + } + + return new MissionListResponseDto( + new MissionListResponseDto.MissionSectionDto(dailyKey, List.copyOf(daily)), + new MissionListResponseDto.MissionSectionDto(weeklyKey, List.copyOf(weekly)), + // 여러 기간이 섞이므로 묶음 단위의 기간 키가 없다. 기간은 항목마다 실려 간다. + new MissionListResponseDto.MissionSectionDto(null, expiredMissions(userId, dailyKey, weeklyKey)) + ); + } + + /** + * 지난 기간에 완료했지만 받지 않은 보상. + * + *

정의가 그 사이 비활성화됐어도 이미 완료한 보상은 받을 수 있어야 하므로 active 조건을 걸지 않는다. + * 정의 자체가 사라진 경우에만 건너뛴다. + */ + private List expiredMissions(Long userId, String dailyKey, String weeklyKey) { + List expired = missionProgressRepository.findUnclaimedFromPastPeriods( + userId, + List.of(dailyKey, weeklyKey), + MissionPeriodKey.now().minusDays(EXPIRED_LOOKBACK_DAYS) + ); + if (expired.isEmpty()) { + return List.of(); + } + + Map definitionsById = missionDefinitionRepository + .findAllById(expired.stream().map(MissionProgress::getMissionId).distinct().toList()) + .stream() + .collect(Collectors.toMap(MissionDefinition::getId, definition -> definition)); + + return expired.stream() + .filter(progress -> definitionsById.containsKey(progress.getMissionId())) + // 최근에 놓친 것을 먼저 보여주고, 시각이 같으면 목록과 같은 순서를 쓴다. + // + // 기간 키 문자열로 정렬하면 안 된다. "2026-W37" 과 "2026-09-09" 는 여섯 번째 글자 + // 'W'(0x57) 대 '0'(0x30) 에서 갈려 주간이 언제나 일일보다 앞선다. + // 어제 놓친 일일 미션이 3주 전 주간 미션보다 아래로 밀린다. + .sorted(Comparator + .comparing(MissionProgress::getCompletedAt, Comparator.reverseOrder()) + .thenComparing(progress -> definitionsById.get(progress.getMissionId()).getSortOrder())) + .map(progress -> MissionResponseDto.from( + definitionsById.get(progress.getMissionId()), progress, progress.getPeriodKey())) + .toList(); + } + + /** + * 보상 획득 기록. 받은 시각 역순, 커서 페이지네이션. + * + *

새 테이블을 두지 않는다. {@code claimed_at} 이 붙은 진행도 행이 곧 기록이다. + * + *

정의가 비활성화된 미션도 그대로 내려간다. 이미 받은 건 받은 것이다. + * 정의 자체가 사라진 경우에만 건너뛴다. + * + * @param cursor 앞 페이지 마지막 항목의 {@code progressId}. 첫 페이지에서는 null. + */ + public MissionClaimHistoryResponseDto getClaimHistory(Long userId, Long cursor, int size) { + int safeSize = Math.min(Math.max(size, 1), MAX_HISTORY_SIZE); + + LocalDateTime cursorClaimedAt = null; + if (cursor != null) { + // 남의 id 를 커서로 넘기면 여기서 걸린다. 못 찾은 커서는 "끝난 목록"으로 본다. + MissionProgress cursorProgress = missionProgressRepository.findByIdAndUserId(cursor, userId).orElse(null); + if (cursorProgress == null || cursorProgress.getClaimedAt() == null) { + return MissionClaimHistoryResponseDto.nextPage(List.of(), null, false, safeSize); + } + cursorClaimedAt = cursorProgress.getClaimedAt(); + } + + // 다음 페이지가 있는지 알려면 한 건 더 읽어 보는 수밖에 없다. 전체를 세는 것보다 싸다. + List claimed = missionProgressRepository.findClaimedPage( + userId, cursorClaimedAt, cursor, PageRequest.of(0, safeSize + 1)); + boolean hasNext = claimed.size() > safeSize; + List pageContent = hasNext ? claimed.subList(0, safeSize) : claimed; + + Map definitionsById = missionDefinitionRepository + .findAllById(pageContent.stream().map(MissionProgress::getMissionId).distinct().toList()) + .stream() + .collect(Collectors.toMap(MissionDefinition::getId, definition -> definition)); + + List content = pageContent.stream() + .filter(progress -> definitionsById.containsKey(progress.getMissionId())) + .map(progress -> MissionClaimHistoryItemDto.from( + definitionsById.get(progress.getMissionId()), progress)) + .toList(); + + Long nextCursor = hasNext && !pageContent.isEmpty() + ? pageContent.get(pageContent.size() - 1).getId() + : null; + + if (cursor != null) { + return MissionClaimHistoryResponseDto.nextPage(content, nextCursor, hasNext, safeSize); + } + return MissionClaimHistoryResponseDto.firstPage( + content, nextCursor, hasNext, safeSize, + missionProgressRepository.sumClaimedXp(userId), + missionProgressRepository.countByUserIdAndClaimedAtIsNotNull(userId) + ); + } + + /** + * 보상 받기. + * + *

남의 진행도는 "없다"로 답한다. 존재 여부를 알려주면 id 를 훑어 다른 사용자의 미션 진행 상황을 + * 알아낼 수 있기 때문이다. + * + *

이미 받았는지는 조회로 한 번 걸러 400 을 주고, 실제 지급은 {@code claimed_at IS NULL} 조건부 + * UPDATE 가 성공한 경우에만 한다. 조회만으로 판단하면 버튼을 두 번 빠르게 누른 두 요청이 모두 + * 통과해 XP 가 두 번 들어간다. + */ + @Transactional + public MissionClaimResponseDto claim(Long userId, Long progressId) { + MissionProgress progress = missionProgressRepository.findById(progressId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND)); + + if (!progress.isOwnedBy(userId)) { + throw new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND); + } + if (!progress.isCompleted()) { + throw new ApplicationException(MissionErrorCase.MISSION_NOT_COMPLETED); + } + if (progress.isClaimed()) { + throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); + } + + MissionDefinition definition = missionDefinitionRepository.findById(progress.getMissionId()) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND)); + + // 진행도 행을 잠그기 전에 사용자 행을 먼저 잠근다. 잠금 순서를 기존 적립 경로와 맞춰 교착을 막는다. + missionRewardGranter.lockUser(userId); + + // 보상 종류와 값을 받는 순간 박아 둔다. 나중에 정의를 바꿔도 이 기록의 값은 그대로다. + if (missionProgressRepository.markClaimed( + progressId, userId, definition.getRewardType().name(), definition.getRewardValue()) == 0) { + throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); + } + + MissionRewardGranter.GrantResult grantResult = missionRewardGranter.grant( + userId, definition.getMetric(), definition.getRewardType(), definition.getRewardValue()); + + // 해금 조회는 지급이 끝난 뒤에 한다. 사용자 행 잠금을 들고 있는 구간을 늘리지 않기 위해서다. + // 총 학습 레벨 구간과 이번에 XP 가 들어간 능력치 레벨 구간을 함께 본다. 능력치 레벨로 열리는 + // 아이템이 대부분이라, 총 학습 레벨만 보면 레벨업 알림이 거의 늘 비어 있게 된다. + // cosmetic_item 은 애플리케이션이 읽기만 하는 카탈로그라 이 조회가 새 잠금 순서를 만들지 않는다. + List unlockedCosmetics = cosmeticService.findUnlockedBetween( + grantResult.levelBefore(), grantResult.totalStudyLevel(), + grantResult.abilityType(), grantResult.abilityLevelBefore(), grantResult.abilityLevelAfter()); + + log.info("userId: {} claimed missionProgressId: {}, code: {}, reward: {} {}, unlocked: {}", + userId, progressId, definition.getCode(), definition.getRewardType(), definition.getRewardValue(), + unlockedCosmetics.stream().map(UnlockedCosmeticDto::itemKey).toList()); + + return new MissionClaimResponseDto( + progressId, + definition.getRewardType(), + definition.getRewardValue(), + grantResult.totalStudyLevel(), + grantResult.leveledUp(), + unlockedCosmetics + ); + } + + private String cacheKey(Long missionId, String periodKey) { + return missionId + "@" + periodKey; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/controller/NoticeController.java b/src/main/java/com/aisip/OnO/backend/notice/controller/NoticeController.java new file mode 100644 index 00000000..21e58402 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/controller/NoticeController.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.notice.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notices") +public class NoticeController { + + private final NoticeService noticeService; + + /** + * 지금 띄울 공지를 내려준다. + * + *

보여줄 공지가 없으면 {@code data} 없이 성공 응답만 나간다. + * ({@code CommonResponse} 가 null 필드를 빼기 때문에 키 자체가 사라진다.) + */ + @GetMapping("/active") + public CommonResponse getActiveNotice() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(noticeService.findActiveNoticeForUser(userId)); + } + + /** + * 그만 보기. 누른 유저에게만 24시간 동안 숨긴다. + */ + @PostMapping("/{noticeId}/dismiss") + public CommonResponse dismissNotice(@PathVariable(name = "noticeId") Long noticeId) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + noticeService.dismissNotice(noticeId, userId); + + return CommonResponse.success("공지를 24시간 동안 숨겼습니다."); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeCreateRequestDto.java b/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeCreateRequestDto.java new file mode 100644 index 00000000..f09bb89e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeCreateRequestDto.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.notice.dto; + +import com.aisip.OnO.backend.notice.entity.NoticeType; + +/** + * 관리자 공지 등록 요청. + * + * @param durationHours 노출 시간. 비우면 기본 24시간으로 채운다. + */ +public record NoticeCreateRequestDto( + String title, + String content, + NoticeType type, + Integer durationHours +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeResponseDto.java b/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeResponseDto.java new file mode 100644 index 00000000..8c278286 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/dto/NoticeResponseDto.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.notice.dto; + +import com.aisip.OnO.backend.notice.entity.NoticeType; +import com.aisip.OnO.backend.notice.entity.ServiceNotice; + +import java.time.LocalDateTime; + +public record NoticeResponseDto( + Long noticeId, + String title, + String content, + NoticeType type, + LocalDateTime expiresAt +) { + public static NoticeResponseDto from(ServiceNotice notice) { + return new NoticeResponseDto( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getType(), + notice.getExpiresAt() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/entity/NoticeType.java b/src/main/java/com/aisip/OnO/backend/notice/entity/NoticeType.java new file mode 100644 index 00000000..e20a8e64 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/entity/NoticeType.java @@ -0,0 +1,11 @@ +package com.aisip.OnO.backend.notice.entity; + +/** + * 공지 성격. 프론트가 팝업 색상과 아이콘을 이 값으로 고른다. + */ +public enum NoticeType { + + INFO, // 일반 안내 + WARNING, // 점검, 장애 같은 주의 안내 + EVENT // 이벤트 홍보 +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/entity/ServiceNotice.java b/src/main/java/com/aisip/OnO/backend/notice/entity/ServiceNotice.java new file mode 100644 index 00000000..97eb423d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/entity/ServiceNotice.java @@ -0,0 +1,59 @@ +package com.aisip.OnO.backend.notice.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +/** + * 서비스 전체에 노출하는 공지 한 건. + * + *

관리자가 제거하면 행을 지우지 않고 soft delete 로 내린다. 어떤 공지를 언제 + * 띄웠는지가 나중에 필요할 수 있어서 이력을 남긴다. + */ +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE service_notice SET deleted_at = now(6) WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "service_notice", indexes = { + @Index(name = "idx_service_notice_active", columnList = "expires_at, starts_at") +}) +public class ServiceNotice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, length = 500) + private String content; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NoticeType type; + + @Column(nullable = false) + private LocalDateTime startsAt; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + public static ServiceNotice of(String title, String content, NoticeType type, + LocalDateTime startsAt, LocalDateTime expiresAt) { + return ServiceNotice.builder() + .title(title) + .content(content) + .type(type) + .startsAt(startsAt) + .expiresAt(expiresAt) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/exception/NoticeErrorCase.java b/src/main/java/com/aisip/OnO/backend/notice/exception/NoticeErrorCase.java new file mode 100644 index 00000000..3455d995 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/exception/NoticeErrorCase.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.notice.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NoticeErrorCase implements ErrorCase { + + NOTICE_NOT_FOUND(404, 14001, "공지를 찾을 수 없습니다."), + + NOTICE_TITLE_INVALID(400, 14002, "공지 제목은 1자 이상 100자 이하여야 합니다."), + + NOTICE_CONTENT_INVALID(400, 14003, "공지 내용은 1자 이상 500자 이하여야 합니다."), + + NOTICE_DURATION_INVALID(400, 14004, "공지 노출 시간은 1시간 이상 168시간 이하여야 합니다."), + + NOTICE_TYPE_REQUIRED(400, 14005, "공지 유형을 지정해야 합니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/repository/ServiceNoticeRepository.java b/src/main/java/com/aisip/OnO/backend/notice/repository/ServiceNoticeRepository.java new file mode 100644 index 00000000..4600b379 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/repository/ServiceNoticeRepository.java @@ -0,0 +1,25 @@ +package com.aisip.OnO.backend.notice.repository; + +import com.aisip.OnO.backend.notice.entity.ServiceNotice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ServiceNoticeRepository extends JpaRepository { + + /** + * 지금 노출해야 하는 공지를 최신순으로 가져온다. + * + *

활성 공지는 한 건만 두기로 했지만 등록이 겹치면 잠깐 둘이 될 수 있어서 + * 단건이 아니라 목록으로 받는다. 조회하는 쪽에서 맨 앞을 쓴다. + * + *

{@code deleted_at IS NULL} 은 엔티티의 {@code @SQLRestriction} 이 붙여 준다. + */ + @Query("SELECT n FROM ServiceNotice n " + + "WHERE n.startsAt <= :now AND n.expiresAt > :now " + + "ORDER BY n.id DESC") + List findActiveNotices(@Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/service/NoticeCacheService.java b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeCacheService.java new file mode 100644 index 00000000..70546167 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeCacheService.java @@ -0,0 +1,97 @@ +package com.aisip.OnO.backend.notice.service; + +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * 지금 노출할 공지를 Redis 에 캐싱한다. + * + *

공지 조회는 앱 메인에 들어올 때마다 호출되는데 실제로 공지가 걸려 있는 날은 + * 드물다. 그래서 공지가 없다는 사실까지 같이 캐싱해서 빈 조회가 DB 로 내려가지 + * 않게 한다. + * + *

TTL 을 60초로 짧게 잡은 이유는 {@code RedisHandler.executeOperation} 이 삭제 + * 실패를 예외 없이 0 으로만 알려 주기 때문이다. 관리자가 공지를 내렸는데 evict 가 + * 실패하면 캐시가 계속 살아 있게 되는데, TTL 이 짧으면 늦어도 60초 뒤에는 스스로 + * 바로잡힌다. 캐시 목적이 트래픽 흡수라 60초로도 DB 부하는 충분히 걷힌다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NoticeCacheService { + + private static final String ACTIVE_KEY = "NOTICE:ACTIVE"; + private static final String EMPTY_MARKER = "NONE"; + private static final Duration TTL = Duration.ofSeconds(60); + + private final RedisSingleDataService redisSingleDataService; + private final ObjectMapper objectMapper; + + /** + * 캐시된 활성 공지를 읽는다. + * + * @return 비어 있으면 캐시 미스라서 DB 를 봐야 한다는 뜻이고, + * 값이 있으면서 {@link ActiveNoticeSnapshot#isEmpty()} 이면 활성 공지가 없다고 캐싱된 상태다. + */ + public Optional read(LocalDateTime now) { + try { + String cached = redisSingleDataService.getSingleData(ACTIVE_KEY); + if (cached == null || cached.isBlank()) { + return Optional.empty(); + } + if (EMPTY_MARKER.equals(cached)) { + return Optional.of(new ActiveNoticeSnapshot(null)); + } + + NoticeResponseDto notice = objectMapper.readValue(cached, NoticeResponseDto.class); + // TTL 안에 공지가 만료될 수 있다. 캐시에 남아 있어도 기간이 지났으면 쓰지 않는다. + if (notice.expiresAt() == null || !notice.expiresAt().isAfter(now)) { + return Optional.empty(); + } + return Optional.of(new ActiveNoticeSnapshot(notice)); + } catch (Exception e) { + log.warn("공지 캐시 조회에 실패했다. reason={}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * 활성 공지를 캐싱한다. {@code notice} 가 null 이면 "공지 없음"으로 캐싱한다. + */ + public void write(NoticeResponseDto notice) { + try { + String value = (notice == null) ? EMPTY_MARKER : objectMapper.writeValueAsString(notice); + redisSingleDataService.setSingleData(ACTIVE_KEY, value, TTL); + } catch (Exception e) { + log.warn("공지 캐시 저장에 실패했다. reason={}", e.getMessage()); + } + } + + /** + * 관리자가 공지를 등록하거나 내렸을 때 캐시를 비운다. + */ + public void evict() { + int result = redisSingleDataService.deleteSingleData(ACTIVE_KEY); + if (result == 0) { + log.warn("공지 캐시 삭제에 실패했다. 최대 {}초 동안 이전 상태가 노출될 수 있다.", TTL.toSeconds()); + } + } + + /** + * 캐시에 담긴 활성 공지 상태. {@code notice} 가 null 이면 활성 공지가 없다는 뜻이다. + */ + public record ActiveNoticeSnapshot(NoticeResponseDto notice) { + + public boolean isEmpty() { + return notice == null; + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/service/NoticeDismissService.java b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeDismissService.java new file mode 100644 index 00000000..cc866cea --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeDismissService.java @@ -0,0 +1,58 @@ +package com.aisip.OnO.backend.notice.service; + +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/** + * "그만 보기"를 누른 유저를 기억한다. + * + *

유저 수만큼 행이 생기는데 24시간이 지나면 의미가 없어지는 값이라 DB 가 아니라 + * Redis 에 두고 TTL 로 정리되게 했다. Redis 가 죽어서 조회에 실패하면 숨기지 않고 + * 그냥 노출하는 쪽으로 둔다. 공지를 한 번 더 보는 것보다 못 보는 쪽이 손해가 크다. + * + *

키에 공지 ID 가 들어가므로 관리자가 공지를 내리고 새로 올리면 이전에 그만 보기를 + * 누른 유저에게도 다시 보인다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NoticeDismissService { + + private static final String KEY_PREFIX = "NOTICE:DISMISS"; + private static final String MARKER = "1"; + private static final Duration MAX_TTL = Duration.ofHours(24); + + private final RedisSingleDataService redisSingleDataService; + + public boolean isDismissed(Long noticeId, Long userId) { + try { + String value = redisSingleDataService.getSingleData(key(noticeId, userId)); + return value != null && !value.isBlank(); + } catch (Exception e) { + log.warn("공지 그만 보기 조회에 실패했다. noticeId={}, userId={}, reason={}", + noticeId, userId, e.getMessage()); + return false; + } + } + + /** + * 24시간 동안 숨긴다. 공지가 그 전에 만료되면 남은 기간만큼만 잡아 키를 남기지 않는다. + * + * @param remaining 공지 만료까지 남은 시간 + */ + public void dismiss(Long noticeId, Long userId, Duration remaining) { + Duration ttl = remaining.compareTo(MAX_TTL) < 0 ? remaining : MAX_TTL; + if (ttl.isNegative() || ttl.isZero()) { + return; + } + redisSingleDataService.setSingleData(key(noticeId, userId), MARKER, ttl); + } + + private String key(Long noticeId, Long userId) { + return KEY_PREFIX + ":" + noticeId + ":" + userId; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/notice/service/NoticeService.java b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeService.java new file mode 100644 index 00000000..7b30d36f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/notice/service/NoticeService.java @@ -0,0 +1,187 @@ +package com.aisip.OnO.backend.notice.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.notice.dto.NoticeCreateRequestDto; +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.notice.entity.NoticeType; +import com.aisip.OnO.backend.notice.entity.ServiceNotice; +import com.aisip.OnO.backend.notice.exception.NoticeErrorCase; +import com.aisip.OnO.backend.notice.repository.ServiceNoticeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NoticeService { + + // 서비스의 하루 기준은 KST 다. 인자 없는 now() 는 JVM 기본 시간대를 쓰기 때문에 + // 서버 시간대 설정이 빠지는 순간 공지 노출 기간이 밀린다. + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private static final int DEFAULT_DURATION_HOURS = 24; + private static final int MAX_DURATION_HOURS = 168; + private static final int MAX_TITLE_LENGTH = 100; + private static final int MAX_CONTENT_LENGTH = 500; + + private final ServiceNoticeRepository serviceNoticeRepository; + private final NoticeCacheService noticeCacheService; + private final NoticeDismissService noticeDismissService; + + /** + * 유저에게 지금 보여줄 공지를 돌려준다. 없거나 이미 그만 보기를 누른 공지면 null 이다. + */ + @Transactional(readOnly = true) + public NoticeResponseDto findActiveNoticeForUser(Long userId) { + LocalDateTime now = LocalDateTime.now(KST); + + // 캐시가 비어 있는 것(미스)과 "활성 공지가 없다"고 캐싱된 것을 구분해야 해서 + // Optional.map 으로 잇지 않고 isPresent 로 가른다. map 을 쓰면 공지 없음이 + // 캐시 미스로 흘러 들어가 매번 DB 를 보게 된다. + Optional cached = noticeCacheService.read(now); + NoticeResponseDto activeNotice = cached.isPresent() + ? cached.get().notice() + : loadAndCacheActiveNotice(now); + + if (activeNotice == null) { + return null; + } + if (noticeDismissService.isDismissed(activeNotice.noticeId(), userId)) { + return null; + } + return activeNotice; + } + + /** + * 그만 보기를 눌렀을 때 그 유저에게만 24시간 동안 숨긴다. + */ + @Transactional(readOnly = true) + public void dismissNotice(Long noticeId, Long userId) { + ServiceNotice notice = serviceNoticeRepository.findById(noticeId) + .orElseThrow(() -> new ApplicationException(NoticeErrorCase.NOTICE_NOT_FOUND)); + + Duration remaining = Duration.between(LocalDateTime.now(KST), notice.getExpiresAt()); + noticeDismissService.dismiss(noticeId, userId, remaining); + } + + /** + * 관리자 화면에서 쓰는 조회. 그만 보기와 무관하게 지금 걸려 있는 공지를 그대로 보여준다. + */ + @Transactional(readOnly = true) + public NoticeResponseDto findActiveNoticeForAdmin() { + return serviceNoticeRepository.findActiveNotices(LocalDateTime.now(KST)).stream() + .findFirst() + .map(NoticeResponseDto::from) + .orElse(null); + } + + /** + * 공지를 등록한다. 활성 공지는 한 건만 두기로 했으므로 걸려 있던 공지는 함께 내린다. + */ + @Transactional + public NoticeResponseDto registerNotice(NoticeCreateRequestDto request) { + String title = validateTitle(request.title()); + String content = validateContent(request.content()); + NoticeType type = validateType(request.type()); + int durationHours = validateDurationHours(request.durationHours()); + + LocalDateTime now = LocalDateTime.now(KST); + List previousNotices = serviceNoticeRepository.findActiveNotices(now); + previousNotices.forEach(serviceNoticeRepository::delete); + + ServiceNotice notice = serviceNoticeRepository.save( + ServiceNotice.of(title, content, type, now, now.plusHours(durationHours)) + ); + evictCacheAfterCommit(); + + log.info("공지를 등록했다. noticeId={}, expiresAt={}, 내린 공지 수={}", + notice.getId(), notice.getExpiresAt(), previousNotices.size()); + return NoticeResponseDto.from(notice); + } + + /** + * 공지를 즉시 내린다. 행은 지우지 않고 soft delete 로 남긴다. + */ + @Transactional + public void removeNotice(Long noticeId) { + ServiceNotice notice = serviceNoticeRepository.findById(noticeId) + .orElseThrow(() -> new ApplicationException(NoticeErrorCase.NOTICE_NOT_FOUND)); + + serviceNoticeRepository.delete(notice); + evictCacheAfterCommit(); + + log.info("공지를 내렸다. noticeId={}", noticeId); + } + + private NoticeResponseDto loadAndCacheActiveNotice(LocalDateTime now) { + NoticeResponseDto notice = serviceNoticeRepository.findActiveNotices(now).stream() + .findFirst() + .map(NoticeResponseDto::from) + .orElse(null); + + // 공지가 없다는 사실도 캐싱해야 빈 조회가 매번 DB 로 내려가지 않는다. + noticeCacheService.write(notice); + return notice; + } + + /** + * 커밋이 끝난 뒤에 캐시를 비운다. + * + *

트랜잭션 안에서 비우면 커밋 전에 들어온 조회가 아직 반영되지 않은 상태를 + * 다시 캐싱해서, 방금 등록하거나 내린 공지가 최대 TTL 동안 어긋난 채로 나간다. + */ + private void evictCacheAfterCommit() { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + noticeCacheService.evict(); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + noticeCacheService.evict(); + } + }); + } + + private String validateTitle(String title) { + if (title == null || title.isBlank() || title.trim().length() > MAX_TITLE_LENGTH) { + throw new ApplicationException(NoticeErrorCase.NOTICE_TITLE_INVALID); + } + return title.trim(); + } + + private String validateContent(String content) { + if (content == null || content.isBlank() || content.trim().length() > MAX_CONTENT_LENGTH) { + throw new ApplicationException(NoticeErrorCase.NOTICE_CONTENT_INVALID); + } + return content.trim(); + } + + private NoticeType validateType(NoticeType type) { + if (type == null) { + throw new ApplicationException(NoticeErrorCase.NOTICE_TYPE_REQUIRED); + } + return type; + } + + private int validateDurationHours(Integer durationHours) { + if (durationHours == null) { + return DEFAULT_DURATION_HOURS; + } + if (durationHours < 1 || durationHours > MAX_DURATION_HOURS) { + throw new ApplicationException(NoticeErrorCase.NOTICE_DURATION_INVALID); + } + return durationHours; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java index 8bf7cb52..1a54c006 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java @@ -9,4 +9,17 @@ public record PracticeNotificationRegisterDto( String repeatType, List weekDays ) { + + private static final String WEEKLY = "weekly"; + + /** + * 주간 반복인데 요일을 하나도 고르지 않은 상태인지 확인한다. + * + *

이 상태는 크론 변환에서 매일 발송으로 되돌아간다. 사용자는 특정 요일만 고른 줄 알면서 + * 매일 알림을 받는다. 그래서 신버전 앱 요청은 진입부에서 400 으로 막는다. 요일을 고르라는 + * 검증이 없는 구버전 앱 요청은 예전 서버와 같게 매일로 저장한다. + */ + public boolean isWeeklyWithoutWeekDays() { + return WEEKLY.equalsIgnoreCase(repeatType) && (weekDays == null || weekDays.isEmpty()); + } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java index 9fe0d3ce..e60e2ed3 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java @@ -10,7 +10,9 @@ public enum PracticeNoteErrorCase implements ErrorCase { PRACTICE_NOTE_NOT_FOUND(404, 6001, "복습 노트를 찾을 수 없습니다."), - PRACTICE_NOTE_USER_UNMATCHED(403, 6002, "복습 노트를 소유한 유저가 아닙니다."); + PRACTICE_NOTE_USER_UNMATCHED(403, 6002, "복습 노트를 소유한 유저가 아닙니다."), + + PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED(400, 6003, "주간 반복 알림은 요일을 최소 하나 선택해야 합니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepositoryImpl.java index 9bf76f17..bae1b1a5 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/repository/PracticeNoteRepositoryImpl.java @@ -87,11 +87,18 @@ public void deleteProblemsFromAllPractice(List deleteProblemIdList) { .execute(); } + /** + * 썸네일 응답에는 문제 매핑이 필요 없다. + * + *

예전에는 여기서도 {@code problemPracticeNoteMappingList} 를 fetch join 했는데, + * 컬렉션 fetch join 과 limit 을 같이 쓰면 Hibernate 가 limit 을 SQL 이 아니라 메모리에서 적용한다 + * (HHH90003004). 즉 커서 페이징인데도 사용자의 복습노트와 매핑을 전부 읽어 온 뒤 잘라 냈다. + * 무한 스크롤 API 에서 페이지 크기와 무관하게 전체를 읽는 셈이라 fetch join 을 걷어냈다. + */ @Override public List findPracticeNotesByUserWithCursor(Long userId, Long cursor, int size) { var query = queryFactory .selectFrom(practiceNote) - .leftJoin(practiceNote.problemPracticeNoteMappingList, problemPracticeNoteMapping).fetchJoin() .where(practiceNote.userId.eq(userId)); // 커서가 있으면 해당 ID 이후부터 조회 diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index 9e77b010..eed1b83c 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -60,6 +60,8 @@ public class PracticeNoteService { private final CustomEmojiValidator customEmojiValidator; + private final PracticeNotificationWeekDayPolicy weekDayPolicy; + private PracticeNote getPracticeEntity(Long practiceId, Long userId){ PracticeNote practiceNote = practiceNoteRepository.findById(practiceId) @@ -71,6 +73,8 @@ private PracticeNote getPracticeEntity(Long practiceId, Long userId){ public Long registerPractice(PracticeNoteRegisterDto practiceNoteRegisterDto, Long userId) { + validatePracticeNotification(practiceNoteRegisterDto.practiceNotification()); + PracticeNote practiceNote = PracticeNote.from(practiceNoteRegisterDto, userId); practiceNoteRepository.save(practiceNote); @@ -149,7 +153,8 @@ public void addPracticeNoteCount(Long userId, Long practiceId, PracticeNoteCompl customEmojiValidator.validateNullable(moodEmojiKey); practiceNote.updatePracticeNoteCount(moodEmojiKey); - // 복습노트 사용 미션 등록 + // 복습노트 사용 미션 등록. 세트 완료 미션 진행도도 이 안에서 함께 오른다. + // 여기서 따로 올리면 같은 세트를 반복 완료하는 것만으로 주간 세트 미션이 채워진다. missionLogService.registerNotePracticeMission(userId, practiceId); log.info("practiceId: {} count has updated", practiceId); @@ -159,21 +164,19 @@ public void updatePracticeInfo(Long userId, PracticeNoteUpdateDto practiceNoteUp Long practiceId = practiceNoteUpdateDto.practiceNoteId(); PracticeNote practiceNote = getPracticeEntity(practiceId, userId); + validatePracticeNotification(practiceNoteUpdateDto.practiceNotification()); + practiceNote.updateTitle(practiceNoteUpdateDto.practiceTitle()); practiceNote.updateNotification(PracticeNotification.from(practiceNoteUpdateDto.practiceNotification())); - if (!practiceNoteUpdateDto.addProblemIdList().isEmpty()) { - practiceNoteUpdateDto.addProblemIdList().forEach(problemId -> { - addProblemToPractice(practiceNote, problemId, userId); - }); - } + // 요청 본문에 두 리스트가 아예 없으면 null 이 들어온다. 예전에는 isEmpty() 를 바로 불러 + // NullPointerException 이 나면서 수정 요청이 500 으로 떨어졌다. + nullSafe(practiceNoteUpdateDto.addProblemIdList()) + .forEach(problemId -> addProblemToPractice(practiceNote, problemId, userId)); - if (!practiceNoteUpdateDto.removeProblemIdList().isEmpty()) { - practiceNoteUpdateDto.removeProblemIdList().forEach(problemId -> { - deletePracticeNoteMapping(practiceNote, problemId); - }); - } + nullSafe(practiceNoteUpdateDto.removeProblemIdList()) + .forEach(problemId -> deletePracticeNoteMapping(practiceNote, problemId)); if (practiceNoteUpdateDto.practiceNotification() != null) { practiceNotificationScheduler.updateNotification(userId, practiceId, practiceNote.getTitle(), practiceNoteUpdateDto.practiceNotification()); @@ -200,9 +203,45 @@ private void deletePracticeWithoutOwnerCheck(Long practiceId) { problemPracticeNoteMappingList.forEach(ProblemPracticeNoteMapping::removeMappingFromProblemAndPractice); practiceNoteRepository.deleteById(practiceId); + + // 복습노트가 사라졌는데 Quartz 잡이 남아 있으면, 지운 복습노트 이름으로 복습 알림이 계속 발송된다. + // 다만 스케줄러 문제로 복습노트 삭제(회원 탈퇴 포함) 자체가 실패하면 안 되므로 삭제 실패는 로그로만 남긴다. + try { + practiceNotificationScheduler.deleteNotification(practiceId); + } catch (RuntimeException e) { + log.error("practiceId: {} 복습 알림 스케줄 삭제 실패", practiceId, e); + } + log.info("practiceId: {} has deleted", practiceId); } + /** + * 주간 반복 알림에 요일이 하나도 없으면 신버전 앱 요청만 400 으로 거절한다. + * + *

이 검증이 요청 진입부에 있는 이유는 복습노트 저장이나 기존 Quartz 잡 삭제가 아예 일어나지 + * 않아야 하기 때문이다. Quartz 잡 삭제는 이 트랜잭션과 함께 롤백되지 않는다. + * + *

구버전 요청은 예전처럼 통과시킨다. 요일이 빈 주간 반복은 스케줄러의 크론 변환에서 + * 매일 발송으로 저장된다. 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 + * 그 사용자는 복습 세트를 영영 수정할 수 없다. 판정 기준은 + * {@link PracticeNotificationWeekDayPolicy} 한 곳에 있다. + */ + private void validatePracticeNotification(PracticeNotificationRegisterDto practiceNotification) { + if (practiceNotification == null || !practiceNotification.isWeeklyWithoutWeekDays()) { + return; + } + + if (weekDayPolicy.requiresWeekDays()) { + throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + } + + log.info("요일 없는 주간 반복 알림을 구버전 앱 요청으로 보고 매일 발송으로 저장한다"); + } + + private List nullSafe(List values) { + return values == null ? List.of() : values; + } + public void deletePractices(Long userId, List practiceIdList) { practiceIdList.forEach(practiceId -> deletePractice(practiceId, userId)); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java index 8dfea878..07edc098 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.practicenote.service; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.extern.slf4j.Slf4j; @@ -31,7 +32,9 @@ public void executeInternal(JobExecutionContext context) { "User Token", "오답노트를 복습할 시간이예요!", practiceTitle + " 복습 세트를 공부할 시간입니다!", - Map.of("practiceId", String.valueOf(practiceId)) + // type 이 없으면 앱이 분기하지 못해 알림을 눌러도 아무 화면이 열리지 않는다. + Map.of("type", NotificationType.PRACTICE_NOTE_REMINDER, + "practiceId", String.valueOf(practiceId)) ) ); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java index 54ec83dc..dcec1af0 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java @@ -60,7 +60,7 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { if ("daily".equalsIgnoreCase(dto.repeatType())) { // 매일 지정된 시각에 실행 return String.format("0 %d %d ? * *", minute, hour); - } else if ("weekly".equalsIgnoreCase(dto.repeatType()) && dto.weekDays() != null && !dto.weekDays().isEmpty()) { + } else if ("weekly".equalsIgnoreCase(dto.repeatType()) && !dto.isWeeklyWithoutWeekDays()) { // 선택한 요일에만 지정된 시각에 실행 (e.g. MON,WED,FRI) String dayString = dto.weekDays().stream() .map(this::convertDayToQuartz) @@ -70,7 +70,12 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { return String.format("0 %d %d ? * %s", minute, hour, dayString); } - // fallback (매일) + // 매일 폴백. 여기로 오는 경우는 둘이다. + // 1) daily/weekly 가 아닌 값(null 포함). 구버전 앱이 repeatType 을 비워 보낸다. + // 2) 주간 반복인데 요일이 비어 있는 구버전 앱 요청. 신버전 요청은 진입부인 + // PracticeNoteService 에서 이미 400 으로 걸러지고 여기까지 오지 않는다. + // 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 그 사용자는 + // 복습 세트를 저장할 수도 수정할 수도 없다. 예전 서버와 같게 매일로 저장한다. return String.format("0 %d %d ? * *", minute, hour); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java new file mode 100644 index 00000000..a50c06dc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java @@ -0,0 +1,55 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 주간 반복 알림에 요일을 강제할 요청인지 정하는 한 곳. + * + *

요일 없는 주간 반복을 400 으로 막는 검증(#302)이 구버전 앱 사용자를 가뒀다. + * 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, + * 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 운영 DB 에 요일이 빈 주간 알림 행이 이미 있고, + * 그 복습 세트를 연 구버전 사용자는 제목만 바꿔도 계속 400 을 받는다. 앱을 올리기 전에는 빠져나갈 길이 없다. + * + *

그래서 요청이 온 앱 버전으로 가른다. 기준 버전 이상에서 온 요청만 막고, 그 아래는 예전처럼 + * 매일 크론으로 저장한다. 프론트는 신버전에서 요일을 강제하므로 신버전 사용자는 이 조합을 만들 수 없다. + * + *

미션의 {@code LegacyAccrualPolicy} 를 그대로 쓰지 않은 이유. 거기에는 자동 적립을 통째로 + * 멈추는 비상 스위치({@code ono.mission.legacy-accrual.enabled})가 붙어 있다. 그 스위치를 내렸을 때 + * 복습 알림 검증까지 같이 움직이면, XP 사고를 막으려고 끈 설정이 알림 저장 동작을 조용히 바꾼다. + * 공용으로 두는 것은 {@link AppVersionResolver#isAtLeast(String)} 의 버전 비교까지다. + */ +@Component +@RequiredArgsConstructor +public class PracticeNotificationWeekDayPolicy { + + private final AppVersionResolver appVersionResolver; + + /** + * 요일 없는 주간 반복을 거절하기 시작하는 첫 앱 버전. + * + *

기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 + * 스토어 빌드는 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 + * 어디까지를 새 앱으로 볼지만 가른다. 헤더를 붙인 커밋과 요일을 강제하는 클라이언트 검증은 + * 같은 미출시 빌드에 들어 있다. + * + *

설정값으로 둔 이유는 요일 강제가 빠진 빌드가 뒤늦게 드러났을 때 배포 없이 기준을 올려 + * 되돌리기 위해서다. + */ + @Value("${ono.practice-note.week-days-required-version:4.0.0}") + private String weekDaysRequiredVersion; + + /** + * 이번 요청에서 주간 반복에 요일을 강제하는가. + * + *

모르면 강제하지 않는다. 헤더가 없는 요청은 구버전이다. 잘못 보면 신버전 사용자가 + * 요일 없는 주간 알림을 하나 저장할 뿐이지만, 반대로 틀리면 구버전 사용자는 복습 세트를 + * 영영 수정할 수 없다. + */ + public boolean requiresWeekDays() { + return appVersionResolver.isAtLeast(weekDaysRequiredVersion); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index 612c3686..cd6511af 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.problem.dto.AddProblemImageUrlsRequest; +import com.aisip.OnO.backend.problem.dto.UpdateProblemImageDataRequest; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; @@ -240,6 +241,23 @@ public CommonResponse deleteUserProblems( return CommonResponse.success("유저의 모든 문제가 삭제되었습니다."); } + // ✅ 문제 이미지 URL 수정 (Flutter 문제 수정 플로우) + @PatchMapping("/imageData") + public CommonResponse updateProblemImageData( + @Validated @RequestBody UpdateProblemImageDataRequest request + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long problemId = request.problemId(); + AddProblemImageUrlsRequest converted = new AddProblemImageUrlsRequest( + request.imageDataDtoList().stream() + .map(item -> new AddProblemImageUrlsRequest.ImageUrlItem(item.imageUrl(), item.problemImageType())) + .toList() + ); + problemService.addImageDataUrls(problemId, userId, converted); + problemService.analysisProblem(problemId, userId); + return CommonResponse.success(null); + } + // ✅ 문제 이미지 데이터 삭제 @DeleteMapping("/imageData") public CommonResponse deleteProblemImageData(@RequestParam("imageUrl") String imageUrl) { diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java index 4f0dc8bb..2ff8c329 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java @@ -1,6 +1,6 @@ package com.aisip.OnO.backend.problem.dto; -import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection; import lombok.Builder; import java.time.LocalDate; @@ -21,14 +21,14 @@ public record ReviewDueProblemDto( int reviewInterval, int consecutiveCorrectCount ) { - public static ReviewDueProblemDto from(Problem problem) { + public static ReviewDueProblemDto from(ReviewDueProblemProjection projection) { return ReviewDueProblemDto.builder() - .problemId(problem.getId()) - .memo(problem.getMemo()) - .reference(problem.getReference()) - .nextReviewAt(problem.getNextReviewAt()) - .reviewInterval(problem.getReviewInterval()) - .consecutiveCorrectCount(problem.getConsecutiveCorrectCount()) + .problemId(projection.problemId()) + .memo(projection.memo()) + .reference(projection.reference()) + .nextReviewAt(projection.nextReviewAt()) + .reviewInterval(projection.reviewInterval()) + .consecutiveCorrectCount(projection.consecutiveCorrectCount()) .build(); } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java b/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java new file mode 100644 index 00000000..e32f1f75 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.problem.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UpdateProblemImageDataRequest( + @NotNull Long problemId, + @NotNull List imageDataDtoList +) { + public record ImageItem(String imageUrl, String problemImageType) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java index 6fa22a86..5ed03a53 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java @@ -28,6 +28,15 @@ }) public class Problem extends BaseEntity { + /** + * 앱이 입력 필드에서 허용하는 메모 길이와 동일하다. + * 길이 제약을 명시하지 않으면 MySQL에서 varchar(255)로 생성돼 + * 256자 이상 입력 시 Data truncation 으로 500이 난다. + */ + public static final int MEMO_MAX_LENGTH = 1000; + + public static final int REFERENCE_MAX_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -38,8 +47,10 @@ public class Problem extends BaseEntity { @JoinColumn(name = "folder_id") private Folder folder; + @Column(length = MEMO_MAX_LENGTH) private String memo; + @Column(length = REFERENCE_MAX_LENGTH) private String reference; private LocalDateTime solvedAt; diff --git a/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java b/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java new file mode 100644 index 00000000..27e75ae2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.problem.event; + +import java.time.LocalDateTime; +import java.util.List; + +public record ProblemCreatedEvent(Long userId, List problems) { + public record ProblemData(Long problemId, String memo, String reference, LocalDateTime createdAt) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java index b6b2f149..22663dda 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java @@ -16,7 +16,13 @@ public enum ProblemErrorCase implements ErrorCase { PROBLEM_ANALYSIS_NOT_FOUND(404, 4004, "문제 분석 결과를 찾을 수 없습니다."), - ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."); + ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."), + + PROBLEM_MEMO_TOO_LONG(400, 4006, "메모는 1000자를 넘을 수 없습니다."), + + PROBLEM_REFERENCE_TOO_LONG(400, 4007, "출처는 255자를 넘을 수 없습니다."), + + PROBLEM_FOLDER_ID_REQUIRED(400, 4008, "폴더 ID는 필수입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java index bfc471aa..a369f78f 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.problem.repository.ReviewDueSummary; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.extern.slf4j.Slf4j; @@ -75,11 +76,12 @@ private void sendReviewNotifications(LocalDate today, LocalDateTime reengagement } long dueCount = dueCountByUserId.get(user.getId()); - fcmService.sendNotificationToAllUserDevice(user.getId(), + if (sendSafely(user.getId(), new NotificationRequestDto("", "오늘의 복습 알림", "오늘 복습할 문제가 " + dueCount + "개 있어요!", - Map.of("type", "review_due"))); - notifiedUserIds.add(user.getId()); + Map.of("type", NotificationType.REVIEW_DUE)))) { + notifiedUserIds.add(user.getId()); + } } if (!notifiedUserIds.isEmpty()) { @@ -101,11 +103,12 @@ private void sendReengagementNotifications(LocalDate today, LocalDateTime reenga for (User user : inactiveUsers) { if (!user.isNotificationEnabled()) continue; - fcmService.sendNotificationToAllUserDevice(user.getId(), + if (sendSafely(user.getId(), new NotificationRequestDto("", "오랜만이에요!", "오답노트를 펼칠 시간이에요. 복습하러 돌아와보세요!", - Map.of("type", "reengagement"))); - notifiedUserIds.add(user.getId()); + Map.of("type", NotificationType.REENGAGEMENT)))) { + notifiedUserIds.add(user.getId()); + } } if (!notifiedUserIds.isEmpty()) { @@ -127,11 +130,12 @@ private void sendLongInactiveReengagementNotifications(LocalDate today, LocalDat for (User user : longInactiveUsers) { if (!user.isNotificationEnabled()) continue; - fcmService.sendNotificationToAllUserDevice(user.getId(), + if (sendSafely(user.getId(), new NotificationRequestDto("", "오답노트가 기다리고 있어요", "한동안 자리를 비우셨네요. 다시 시작하기 딱 좋은 날이에요!", - Map.of("type", "reengagement_monthly"))); - notifiedUserIds.add(user.getId()); + Map.of("type", NotificationType.REENGAGEMENT_MONTHLY)))) { + notifiedUserIds.add(user.getId()); + } } if (!notifiedUserIds.isEmpty()) { @@ -139,4 +143,22 @@ private void sendLongInactiveReengagementNotifications(LocalDate today, LocalDat } log.info("[ReviewDue] 장기 미접속 알림 발송: {}명", notifiedUserIds.size()); } -} \ No newline at end of file + + /** + * 한 사용자에게 보내다 실패해도 배치 전체를 멈추면 안 된다. + * 예전에는 발송 예외가 그대로 올라와 뒤에 남은 사용자들이 그날 알림을 통째로 놓쳤다. + * + * @return 발송을 시도해 예외 없이 끝났으면 true. 실패한 사용자는 발송 이력을 남기지 않아 + * 다음 실행에서 다시 대상이 된다. + */ + private boolean sendSafely(Long userId, NotificationRequestDto request) { + try { + fcmService.sendNotificationToAllUserDevice(userId, request); + return true; + } catch (Exception e) { + log.warn("[ReviewDue] 알림 발송 실패 - userId: {}, type: {}, reason: {}", + userId, request.data().get("type"), e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java new file mode 100644 index 00000000..b27e1146 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java @@ -0,0 +1,85 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.problem.entity.Problem; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "problem_review_reminder", indexes = { + @Index(name = "idx_problem_review_reminder_due", columnList = "status, scheduled_at"), + @Index(name = "idx_problem_review_reminder_user_due", columnList = "user_id, status, scheduled_at"), + @Index(name = "idx_problem_review_reminder_problem", columnList = "problem_id, status"), +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_problem_review_reminder_seq", columnNames = {"problem_id", "sequence"}) +}) +public class ProblemReviewReminder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long problemId; + + /** Problem.memo 를 그대로 복사하므로 길이 제약도 같이 맞춰야 한다. */ + @Column(length = Problem.MEMO_MAX_LENGTH) + private String problemMemoSnapshot; + + @Column(length = Problem.REFERENCE_MAX_LENGTH) + private String problemReferenceSnapshot; + + @Column(nullable = false) + private int sequence; + + @Column(nullable = false) + private int intervalDays; + + @Column(nullable = false) + private LocalDateTime scheduledAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private ProblemReviewReminderStatus status; + + private LocalDateTime sentAt; + + @Column(length = 500) + private String lastErrorMessage; + + @Column(nullable = false) + @Builder.Default + private int retryCount = 0; + + /** + * 스냅샷은 잘라 담지 않는다. + * + *

memo 스냅샷 컬럼은 V25 에서 varchar(1000) 으로 넓혀 {@code Problem.MEMO_MAX_LENGTH} 와 같고, + * reference 스냅샷은 varchar(255) 로 {@code Problem.REFERENCE_MAX_LENGTH} 와 같다. + * 입력 길이는 저장 전에 ProblemService 에서 걸러지므로 여기서 자르면 알림 문구만 손실된다. + */ + public static ProblemReviewReminder create( + Long userId, Long problemId, String memo, String reference, + int sequence, int intervalDays, LocalDateTime scheduledAt) { + return ProblemReviewReminder.builder() + .userId(userId) + .problemId(problemId) + .problemMemoSnapshot(memo) + .problemReferenceSnapshot(reference) + .sequence(sequence) + .intervalDays(intervalDays) + .scheduledAt(scheduledAt) + .status(ProblemReviewReminderStatus.SCHEDULED) + .retryCount(0) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java new file mode 100644 index 00000000..9e3960ea --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.problem.reminder; + +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.JobExecutionContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 5분마다 due 복습 알림을 내보내는 폴링 잡. + * + *

한 실행이 5분을 넘기면 다음 실행이 겹쳐 같은 사용자를 두 번 처리할 수 있어 중첩을 막는다. + * 다만 스케줄러가 클러스터 모드가 아니라(application-prod.yml 의 isClustered: false) blue/green + * 두 인스턴스 사이의 동시 폴링은 이것으로 막히지 않는다. 그건 findDueReminders 의 + * "오늘 이미 처리한 사용자" 판정이 막는다. + */ +@Slf4j +@DisallowConcurrentExecution +public class ProblemReviewReminderJob extends QuartzJobBean { + + @Autowired + private ProblemReviewReminderService reminderService; + + @Override + protected void executeInternal(JobExecutionContext context) { + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + log.info("[ReviewReminder] polling 시작 - {}", now); + try { + reminderService.sendDueReminders(now); + } catch (Exception e) { + log.error("[ReviewReminder] polling 실패", e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java new file mode 100644 index 00000000..c97dc3a3 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java @@ -0,0 +1,76 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.util.fcm.NotificationType; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; + +@Component +public class ProblemReviewReminderPolicy { + + private static final List INTERVALS = List.of(1, 3, 7, 14, 30); + private static final LocalTime SEND_WINDOW_START = LocalTime.of(9, 0); + private static final LocalTime SEND_WINDOW_END = LocalTime.of(21, 0); + private static final String NOTIFICATION_TITLE = "오답노트 복습"; + private static final String NOTIFICATION_BODY_DEFAULT = "그때 남긴 오답, 다시 풀어볼 시간이에요"; + private static final String NOTIFICATION_BODY_FALLBACK = "이전에 남긴 오답노트를 다시 풀어보세요"; + + public List getIntervals() { + return INTERVALS; + } + + /** + * 예약 시각을 보내도 되는 시간대 안으로 밀어 넣는다. + * + *

이른 새벽이면 같은 날 아침으로 당기고, 밤이면 다음 날 아침으로 미룬다. + * 예전에는 경계가 {@code NIGHT_END}(06:00) 하나뿐이라 새벽만 막았고, + * 22시에 등록하면 D+N 22시에 그대로 나갔다. + */ + public LocalDateTime calculateScheduledAt(LocalDateTime createdAt, int intervalDays) { + LocalDateTime candidate = createdAt.plusDays(intervalDays); + LocalTime candidateTime = candidate.toLocalTime(); + + if (candidateTime.isBefore(SEND_WINDOW_START)) { + return candidate.toLocalDate().atTime(SEND_WINDOW_START); + } + if (!candidateTime.isBefore(SEND_WINDOW_END)) { + return candidate.toLocalDate().plusDays(1).atTime(SEND_WINDOW_START); + } + return candidate; + } + + /** + * 지금 보내도 되는 시간인가. + * + *

예약 시각을 창 안으로 맞춰 두어도 밀린 예약은 창 밖에서 조건을 만족한다. + * 특히 자정에 "오늘 보낸 것이 있는가" 판정이 리셋되면서 밀린 예약이 한꺼번에 + * due 가 되므로, 예약을 만들 때만이 아니라 보낼 때도 시각을 확인해야 한다. + */ + public boolean isWithinSendWindow(LocalDateTime now) { + LocalTime time = now.toLocalTime(); + return !time.isBefore(SEND_WINDOW_START) && time.isBefore(SEND_WINDOW_END); + } + + public NotificationRequestDto buildNotification(ProblemReviewReminder reminder) { + boolean hasContent = hasContent(reminder.getProblemMemoSnapshot()) + || hasContent(reminder.getProblemReferenceSnapshot()); + String body = hasContent ? NOTIFICATION_BODY_DEFAULT : NOTIFICATION_BODY_FALLBACK; + + Map data = Map.of( + "type", NotificationType.PROBLEM_REVIEW_REMINDER, + "problemId", String.valueOf(reminder.getProblemId()), + "sequence", String.valueOf(reminder.getSequence()), + "intervalDays", String.valueOf(reminder.getIntervalDays()) + ); + + return new NotificationRequestDto("", NOTIFICATION_TITLE, body, data); + } + + private boolean hasContent(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java new file mode 100644 index 00000000..cadc3a96 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -0,0 +1,166 @@ +package com.aisip.OnO.backend.problem.reminder; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +public interface ProblemReviewReminderRepository extends JpaRepository { + + boolean existsByProblemIdAndSequence(Long problemId, int sequence); + + /** + * "오늘 이 사용자를 아직 건드리지 않았는가" 로 후보를 고른다. + * + *

SENT 만 보면 선점(SENDING)과 SENT 기록 사이에 창이 생긴다. 선점은 별도 트랜잭션으로 바로 + * 커밋되므로, 인스턴스 A 가 예약 X 를 SENDING 으로 커밋한 직후 인스턴스 B 가 폴링하면 X 는 + * SCHEDULED 가 아니라 후보에서 빠지고 그 사용자의 SENT 행도 아직 없어서, 같은 사용자의 다른 + * 예약 Y 가 하루 두 번째 푸시로 나갔다. 그래서 오늘 선점된 SENDING 행도 "이미 처리함" 으로 센다. + * + *

선점 시각은 {@code tryUpdateStatus} 가 넣는 {@code updatedAt} 이다. SENDING 이 영영 막지는 + * 않는다 — 10분 넘게 멈춘 행은 stuck 복구가 FAILED 로 넘긴다. + */ + @Query(""" + SELECT r FROM ProblemReviewReminder r + WHERE r.status = :status + AND r.scheduledAt <= :now + AND r.deletedAt IS NULL + AND NOT EXISTS ( + SELECT 1 FROM ProblemReviewReminder handled + WHERE handled.userId = r.userId + AND handled.deletedAt IS NULL + AND ( + (handled.status = :sent + AND handled.sentAt >= :startOfDay AND handled.sentAt < :endOfDay) + OR + (handled.status = :sending + AND handled.updatedAt >= :startOfDay AND handled.updatedAt < :endOfDay) + ) + ) + AND EXISTS ( + SELECT 1 FROM User u + WHERE u.id = r.userId + AND u.notificationEnabled = true + ) + AND EXISTS ( + SELECT 1 FROM FcmToken t + WHERE t.userId = r.userId + ) + ORDER BY r.scheduledAt ASC, r.userId ASC + """) + List findDueReminders( + @Param("status") ProblemReviewReminderStatus status, + @Param("now") LocalDateTime now, + @Param("sent") ProblemReviewReminderStatus sent, + @Param("sending") ProblemReviewReminderStatus sending, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay, + Pageable pageable + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :newStatus, r.updatedAt = :updatedAt WHERE r.id = :id AND r.status = :expectedStatus") + int tryUpdateStatus( + @Param("id") Long id, + @Param("expectedStatus") ProblemReviewReminderStatus expectedStatus, + @Param("newStatus") ProblemReviewReminderStatus newStatus, + @Param("updatedAt") LocalDateTime updatedAt + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :sent, r.sentAt = :sentAt WHERE r.id = :id") + void markSent( + @Param("id") Long id, + @Param("sentAt") LocalDateTime sentAt, + @Param("sent") ProblemReviewReminderStatus sent + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = :errorMessage WHERE r.id = :id") + void markFailed( + @Param("id") Long id, + @Param("errorMessage") String errorMessage, + @Param("failed") ProblemReviewReminderStatus failed + ); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :expired, r.updatedAt = :updatedAt WHERE r.status = :scheduled AND r.scheduledAt < :expireBefore AND r.deletedAt IS NULL") + int expireOverdueRows( + @Param("scheduled") ProblemReviewReminderStatus scheduled, + @Param("expired") ProblemReviewReminderStatus expired, + @Param("expireBefore") LocalDateTime expireBefore, + @Param("updatedAt") LocalDateTime updatedAt + ); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = 'stuck recovery' WHERE r.status = :sending AND r.updatedAt < :stuckBefore AND r.deletedAt IS NULL") + int recoverStuckRows( + @Param("sending") ProblemReviewReminderStatus sending, + @Param("failed") ProblemReviewReminderStatus failed, + @Param("stuckBefore") LocalDateTime stuckBefore + ); + + /** + * 취소할 예약의 ID 만 먼저 읽는다. 잠금을 잡지 않는 일반 조회다. (#319) + * + * @see #cancelByIdIn + */ + @Query("SELECT r.id FROM ProblemReviewReminder r WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + List findPendingIdsByProblem( + @Param("problemId") Long problemId, + @Param("pendingStatuses") List pendingStatuses + ); + + /** + * 예약을 기본 키로 취소한다. (#319) + * + *

예전에는 {@code WHERE problem_id = ?} 로 한 번에 UPDATE 했다. 그러면 REPEATABLE READ 에서 + * {@code uq_problem_review_reminder_seq(problem_id, sequence)} 를 범위로 훑으면서 next-key lock 이 + * 마지막 일치 항목 뒤의 갭까지 잡는다. {@code problem_id} 는 계속 커지므로 그 갭은 대개 + * supremum(인덱스 끝) 이고, 그러면 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 전부 막힌다. 문제 등록은 커밋 직후 예약을 넣기 때문에 + * ({@code scheduleForNewProblems}), 폴더 하나 지우는 동안 다른 계정의 등록까지 잠금 대기에 걸렸다. + * + *

기본 키 등치 조회는 {@code REC_NOT_GAP} 이라 갭을 잡지 않는다. + */ + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.id IN :ids") + int cancelByIdIn( + @Param("ids") Collection ids, + @Param("canceled") ProblemReviewReminderStatus canceled + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.problemMemoSnapshot = :memo, r.problemReferenceSnapshot = :reference WHERE r.problemId = :problemId AND r.status = :scheduled AND r.deletedAt IS NULL") + int refreshSnapshot( + @Param("problemId") Long problemId, + @Param("memo") String memo, + @Param("reference") String reference, + @Param("scheduled") ProblemReviewReminderStatus scheduled + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.userId = :userId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + int cancelAllByUser( + @Param("userId") Long userId, + @Param("canceled") ProblemReviewReminderStatus canceled, + @Param("pendingStatuses") List pendingStatuses + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :skipped WHERE r.problemId = :problemId AND r.status = :scheduled AND r.scheduledAt <= :practicedAt AND r.deletedAt IS NULL") + int skipDuePendingByProblem( + @Param("problemId") Long problemId, + @Param("skipped") ProblemReviewReminderStatus skipped, + @Param("scheduled") ProblemReviewReminderStatus scheduled, + @Param("practicedAt") LocalDateTime practicedAt + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java new file mode 100644 index 00000000..e519ac20 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java @@ -0,0 +1,53 @@ +package com.aisip.OnO.backend.problem.reminder; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.stereotype.Component; + +import java.util.TimeZone; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProblemReviewReminderScheduler { + + private static final String JOB_NAME = "problem-review-reminder"; + private static final String JOB_GROUP = "reminder"; + private static final String TRIGGER_NAME = "problem-review-reminder-trigger"; + private static final String CRON = "0 */5 * ? * *"; + + private final Scheduler scheduler; + + @PostConstruct + public void schedule() { + try { + JobDetail jobDetail = JobBuilder.newJob(ProblemReviewReminderJob.class) + .withIdentity(JOB_NAME, JOB_GROUP) + .storeDurably(true) + .build(); + + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_NAME, JOB_GROUP) + .withSchedule( + CronScheduleBuilder.cronSchedule(CRON) + .inTimeZone(TimeZone.getTimeZone("Asia/Seoul")) + ) + .forJob(jobDetail) + .build(); + + scheduler.addJob(jobDetail, true); + + if (scheduler.checkExists(trigger.getKey())) { + scheduler.rescheduleJob(trigger.getKey(), trigger); + } else { + scheduler.scheduleJob(trigger); + } + + log.info("[ReviewReminder] 스케줄 등록 완료 - cron: {} (Asia/Seoul)", CRON); + } catch (SchedulerException e) { + log.error("[ReviewReminder] 스케줄 등록 실패", e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java new file mode 100644 index 00000000..04ac13e7 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java @@ -0,0 +1,92 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; + +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; + +/** + * 복습 알림 한 건을 선점하고 큐에 넣은 뒤 결과를 기록한다. + * + *

선점, 큐 적재, 결과 기록을 한 트랜잭션에 묶으면 적재 예외가 트랜잭션을 rollback-only 로 만들어 + * SENDING 선점과 FAILED 기록이 함께 사라지고, 커밋 예외가 폴링 루프까지 올라가 뒤 사용자들이 발송되지 않았다. + * 그래서 선점과 결과 기록은 각각 짧은 새 트랜잭션으로 커밋하고, 큐 적재는 트랜잭션 밖에서 한다. + * 브로커 응답을 기다리는 동안 DB 커넥션을 잡고 있지도 않는다. + * + *

{@link #send} 는 예외를 던지지 않는다. 한 건의 실패가 배치 전체를 멈추면 안 되기 때문이다. + */ +@Slf4j +@Component +public class ProblemReviewReminderSender { + + private final ProblemReviewReminderRepository repository; + private final ProblemReviewReminderPolicy policy; + private final FcmService fcmService; + private final TransactionTemplate newTransaction; + + public ProblemReviewReminderSender(ProblemReviewReminderRepository repository, + ProblemReviewReminderPolicy policy, + FcmService fcmService, + PlatformTransactionManager transactionManager) { + this.repository = repository; + this.policy = policy; + this.fcmService = fcmService; + this.newTransaction = new TransactionTemplate(transactionManager); + this.newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public void send(ProblemReviewReminder candidate, LocalDateTime now) { + Long userId = candidate.getUserId(); + Long reminderId = candidate.getId(); + + try { + Integer claimed = newTransaction.execute(status -> + repository.tryUpdateStatus(reminderId, SCHEDULED, SENDING, now)); + if (claimed == null || claimed == 0) { + log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", reminderId); + return; + } + } catch (Exception e) { + log.error("[ReviewReminder] 선점 중 오류 - userId: {}, reminderId: {}", userId, reminderId, e); + return; + } + + try { + NotificationRequestDto dto = policy.buildNotification(candidate); + fcmService.sendNotificationToAllUserDevice(userId, dto); + } catch (Exception e) { + log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, reminderId, e); + recordFailure(userId, reminderId, e); + return; + } + + try { + newTransaction.executeWithoutResult(status -> repository.markSent(reminderId, now, SENT)); + log.info("[ReviewReminder] 발송 완료 - userId: {}, reminderId: {}, sequence: {}", + userId, reminderId, candidate.getSequence()); + } catch (Exception e) { + // 이미 큐에 들어갔으므로 FAILED 로 덮지 않는다. SENDING 으로 남은 행은 stuck 복구가 정리하고, + // SCHEDULED 로 되돌리지 않으니 같은 알림이 다시 나가지도 않는다. + log.error("[ReviewReminder] 적재 후 SENT 기록 실패 - userId: {}, reminderId: {}", userId, reminderId, e); + } + } + + private void recordFailure(Long userId, Long reminderId, Exception cause) { + String errorMsg = cause.getMessage(); + if (errorMsg != null && errorMsg.length() > 500) errorMsg = errorMsg.substring(0, 500); + String truncated = errorMsg; + try { + newTransaction.executeWithoutResult(status -> repository.markFailed(reminderId, truncated, FAILED)); + } catch (Exception e) { + // SENDING 으로 남은 행은 stuck 복구가 FAILED 로 넘긴다. + log.error("[ReviewReminder] FAILED 기록 실패 - userId: {}, reminderId: {}", userId, reminderId, e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java new file mode 100644 index 00000000..c821e971 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -0,0 +1,175 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProblemReviewReminderService { + + private static final int STUCK_TIMEOUT_MINUTES = 10; + private static final int DUE_REMINDER_BATCH_SIZE = 500; + private static final int OVERDUE_EXPIRE_DAYS = 3; + private static final List PENDING_STATUSES = List.of(SCHEDULED, SENDING); + + private final ProblemReviewReminderRepository repository; + private final ProblemReviewReminderPolicy policy; + private final FcmService fcmService; + private final ProblemReviewReminderSender sender; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handleProblemCreated(ProblemCreatedEvent event) { + try { + scheduleForNewProblems(event.userId(), event.problems()); + } catch (Exception e) { + log.error("[ReviewReminder] 자동 알림 예약 실패 - userId: {}", event.userId(), e); + } + } + + @Transactional(propagation = REQUIRES_NEW) + public void scheduleForNewProblems(Long userId, List problems) { + List intervals = policy.getIntervals(); + List reminders = new ArrayList<>(); + + for (ProblemCreatedEvent.ProblemData data : problems) { + for (int i = 0; i < intervals.size(); i++) { + int sequence = i + 1; + int intervalDays = intervals.get(i); + if (repository.existsByProblemIdAndSequence(data.problemId(), sequence)) { + continue; + } + LocalDateTime scheduledAt = policy.calculateScheduledAt(data.createdAt(), intervalDays); + reminders.add(ProblemReviewReminder.create( + userId, data.problemId(), data.memo(), data.reference(), + sequence, intervalDays, scheduledAt + )); + } + } + + if (!reminders.isEmpty()) { + repository.saveAll(reminders); + log.info("[ReviewReminder] 자동 알림 예약 생성 - userId: {}, 문제 {}개, row {}개", + userId, problems.size(), reminders.size()); + } + } + + /** + * 문제 삭제로 남은 예약을 취소한다. + * + *

취소 대상을 먼저 읽고 기본 키로 UPDATE 한다. {@code problem_id} 조건으로 바로 UPDATE 하면 + * next-key lock 이 인덱스 끝의 갭까지 잡아서, 폴더 삭제가 커밋될 때까지 다른 계정의 문제 등록이 + * 전부 예약 INSERT 에서 막혔다. (#319, {@link ProblemReviewReminderRepository#cancelByIdIn}) + */ + @Transactional + public void cancelPendingByProblem(Long problemId) { + List pendingIds = repository.findPendingIdsByProblem(problemId, PENDING_STATUSES); + if (pendingIds.isEmpty()) { + return; + } + + int count = repository.cancelByIdIn(pendingIds, CANCELED); + if (count > 0) { + log.info("[ReviewReminder] 문제 삭제로 알림 취소 - problemId: {}, {}건", problemId, count); + } + } + + @Transactional + public void refreshSnapshot(Long problemId, String memo, String reference) { + repository.refreshSnapshot(problemId, memo, reference, SCHEDULED); + } + + @Transactional + public void skipDuePendingByProblemSolve(Long userId, Long problemId, LocalDateTime practicedAt) { + int count = repository.skipDuePendingByProblem(problemId, SKIPPED_BY_COMPLETION, SCHEDULED, practicedAt); + if (count > 0) { + log.info("[ReviewReminder] 복습 완료로 due 알림 skip - userId: {}, problemId: {}, {}건", + userId, problemId, count); + } + } + + @Transactional + public void cancelAllByUser(Long userId) { + int count = repository.cancelAllByUser(userId, CANCELED, PENDING_STATUSES); + if (count > 0) { + log.info("[ReviewReminder] 사용자 탈퇴로 알림 취소 - userId: {}, {}건", userId, count); + } + } + + public void sendDueReminders(LocalDateTime now) { + recoverStuckRows(now); + expireOverdueRows(now); + + // 예약 시각을 창 안으로 맞춰 두어도 밀린 예약은 창 밖에서 due 가 된다. + // 특히 자정에 "오늘 보낸 것" 판정이 리셋되는 순간 가장 오래된 밀린 예약이 + // 바로 조건을 만족해, 밀린 것이 있는 사용자는 매일 새벽에 푸시를 받게 된다. + if (!policy.isWithinSendWindow(now)) { + return; + } + + LocalDate today = now.toLocalDate(); + LocalDateTime startOfDay = today.atStartOfDay(); + LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); + + // 오늘 SENT 뿐 아니라 오늘 선점된 SENDING 행이 있는 사용자도 후보에서 뺀다. + // 선점이 바로 커밋되므로, 다른 인스턴스가 선점한 직후 폴링이 돌면 SENT 만으로는 중복을 못 막는다. + List dueRows = repository.findDueReminders( + SCHEDULED, now, SENT, SENDING, startOfDay, endOfDay, PageRequest.of(0, DUE_REMINDER_BATCH_SIZE) + ); + if (dueRows.isEmpty()) return; + + Map> byUser = dueRows.stream() + .collect(Collectors.groupingBy(ProblemReviewReminder::getUserId)); + + for (Map.Entry> entry : byUser.entrySet()) { + ProblemReviewReminder candidate = entry.getValue().stream() + .min(Comparator.comparing(ProblemReviewReminder::getScheduledAt)) + .orElseThrow(); + sender.send(candidate, now); + } + } + + /** + * 기한이 한참 지난 예약을 접는다. + * + *

하루에 한 건만 나가므로 같은 날 due 가 겹치면 나머지는 {@code SCHEDULED} 로 남는다. + * 이걸 정리하지 않으면 영원히 밀린 채로 남아 매일 한 건씩 나가게 된다. + * 사흘이 지난 복습 알림은 지금 보내도 의미가 없으니 보내지 않는다. + */ + private void expireOverdueRows(LocalDateTime now) { + LocalDateTime expireBefore = now.minusDays(OVERDUE_EXPIRE_DAYS); + int expired = repository.expireOverdueRows(SCHEDULED, EXPIRED, expireBefore, now); + if (expired > 0) { + log.info("[ReviewReminder] 기한이 지난 예약 만료: {}건", expired); + } + } + + private void recoverStuckRows(LocalDateTime now) { + LocalDateTime stuckBefore = now.minusMinutes(STUCK_TIMEOUT_MINUTES); + int recovered = repository.recoverStuckRows(SENDING, FAILED, stuckBefore); + if (recovered > 0) { + log.warn("[ReviewReminder] SENDING stuck row 복구: {}건", recovered); + } + } + +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java new file mode 100644 index 00000000..314ebd43 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java @@ -0,0 +1,5 @@ +package com.aisip.OnO.backend.problem.reminder; + +public enum ProblemReviewReminderStatus { + SCHEDULED, SENDING, SENT, FAILED, CANCELED, SKIPPED_BY_COMPLETION, EXPIRED +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java index fdd8c3c9..f743da5b 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java @@ -12,8 +12,14 @@ public interface ProblemRepository extends JpaRepository, Problem Long countByUserId(Long userId); - @Query("SELECT p FROM Problem p WHERE p.userId = :userId AND p.nextReviewAt <= :today ORDER BY p.nextReviewAt ASC") - List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); + @Query(""" + SELECT new com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection( + p.id, p.memo, p.reference, p.nextReviewAt, p.reviewInterval, p.consecutiveCorrectCount) + FROM Problem p + WHERE p.userId = :userId AND p.nextReviewAt <= :today + ORDER BY p.nextReviewAt ASC + """) + List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); @Query("SELECT p.userId as userId, COUNT(p) as dueCount FROM Problem p WHERE p.nextReviewAt <= :today GROUP BY p.userId") List findReviewDueSummaryByDate(@Param("today") LocalDate today); diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java index 63a09041..19edd406 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; import org.springframework.data.domain.Page; @@ -21,6 +22,13 @@ public interface ProblemRepositoryCustom { List findAll(); + /** + * 복습 예정 문제를 DTO 로 바로 조회한다. + * Problem 엔티티로 읽으면 @OneToOne(mappedBy) problemAnalysis 때문에 + * 행마다 problem_analysis 조회가 한 번씩 더 나간다 (Sentry JAVA-SPRING-BOOT-59). + */ + List findReviewDueProblemDtos(Long userId, LocalDate today); + Page findAdminProblems(Pageable pageable); Map countDailyProblems(LocalDate startDate, LocalDate endDate); diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java index e427f186..ac80d5c1 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java @@ -1,11 +1,13 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.entity.QProblem; import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.DateExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -51,12 +53,18 @@ public Optional findProblemWithImageData(Long problemId) { return Optional.ofNullable(problem); } + /** + * problemAnalysis 는 mappedBy OneToOne 이라 프록시로 미룰 수도, 배치 페치로 묶을 수도 없다. + * fetch join 하지 않으면 Hibernate 가 "행마다 분석이 있는지" 확인하는 쿼리를 한 번씩 더 던져 + * 문제 개수에 비례해 쿼리가 늘어난다(N+1). 응답 DTO 가 이 값을 항상 읽으므로 함께 가져온다. + */ @Override public List findAllByUserId(Long userId) { return queryFactory .selectFrom(problem) .leftJoin(QProblem.problem.folder).fetchJoin() .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .leftJoin(problem.problemAnalysis, problemAnalysis).fetchJoin() .where(problem.userId.eq(userId)) .orderBy(problem.id.asc()) .fetch(); @@ -68,6 +76,7 @@ public List findAllByFolderId(Long folderId) { .selectFrom(problem) .leftJoin(QProblem.problem.folder).fetchJoin() .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .leftJoin(problem.problemAnalysis, problemAnalysis).fetchJoin() .where(problem.folder.id.eq(folderId)) .orderBy(problem.id.asc()) .fetch(); @@ -83,6 +92,25 @@ public List findAll() { .fetch(); } + @Override + public List findReviewDueProblemDtos(Long userId, LocalDate today) { + return queryFactory + .select(Projections.constructor( + ReviewDueResponseDto.ReviewDueProblemDto.class, + problem.id, + problem.memo, + problem.reference, + problem.nextReviewAt, + problem.reviewInterval, + problem.consecutiveCorrectCount + )) + .from(problem) + .where(problem.userId.eq(userId) + .and(problem.nextReviewAt.loe(today))) + .orderBy(problem.nextReviewAt.asc()) + .fetch(); + } + @Override public Page findAdminProblems(Pageable pageable) { List content = queryFactory @@ -195,66 +223,80 @@ public List findAllProblemsByPracticeId(Long practiceId) { .fetch(); } - @Override - public List findProblemsByFolderWithCursor(Long folderId, Long cursor, int size) { - var query = queryFactory - .selectFrom(problem) - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() - .where(problem.folder.id.eq(folderId)); + /** + * 커서 페이징 + 컬렉션 fetch join 을 한 쿼리에 같이 쓰면 조인으로 행이 뻥튀기돼 + * Hibernate 가 SQL 에 LIMIT 을 걸지 못하고 조건에 맞는 행을 전부 메모리로 읽은 뒤 + * 자바에서 잘라낸다 (경고 HHH90003004). size 를 보내도 폴더의 모든 문제가 올라왔다. + * + * 그래서 1단계에서 id 만 limit 으로 뽑고, 2단계에서 그 id 로 fetch join 한다. + */ + private BooleanExpression cursorAfter(Long cursor) { + return cursor == null ? null : problem.id.gt(cursor); + } - // 커서가 있으면 해당 ID 이후부터 조회 - if (cursor != null) { - query.where(problem.id.gt(cursor)); + private List fetchProblemsWithImages(List problemIds) { + if (problemIds.isEmpty()) { + return List.of(); } - return query + return queryFactory + .selectDistinct(problem) + .from(problem) + .leftJoin(problem.folder).fetchJoin() + .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .where(problem.id.in(problemIds)) + .orderBy(problem.id.asc()) + .fetch(); + } + + @Override + public List findProblemsByFolderWithCursor(Long folderId, Long cursor, int size) { + List problemIds = queryFactory + .select(problem.id) + .from(problem) + .where( + problem.folder.id.eq(folderId), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) // hasNext 판단을 위해 +1개 조회 .fetch(); + + return fetchProblemsWithImages(problemIds); } @Override public List findProblemsByTagWithCursor(Long tagId, Long userId, Long cursor, int size) { - var query = queryFactory - .selectDistinct(problem) + List problemIds = queryFactory + .selectDistinct(problem.id) .from(problem) .join(problemTagMapping).on(problemTagMapping.problem.id.eq(problem.id)) - .leftJoin(problem.folder).fetchJoin() - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() .where( problemTagMapping.tag.id.eq(tagId), - problem.userId.eq(userId) - ); - - if (cursor != null) { - query.where(problem.id.gt(cursor)); - } - - return query + problem.userId.eq(userId), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) .fetch(); + + return fetchProblemsWithImages(problemIds); } @Override public List findProblemsByTitleWithCursor(String titleQuery, Long userId, Long cursor, int size) { - var query = queryFactory - .selectDistinct(problem) + List problemIds = queryFactory + .select(problem.id) .from(problem) - .leftJoin(problem.folder).fetchJoin() - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() .where( problem.userId.eq(userId), - problem.reference.containsIgnoreCase(titleQuery) - ); - - if (cursor != null) { - query.where(problem.id.gt(cursor)); - } - - return query + problem.reference.containsIgnoreCase(titleQuery), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) .fetch(); + + return fetchProblemsWithImages(problemIds); } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java new file mode 100644 index 00000000..d87b9481 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java @@ -0,0 +1,20 @@ +package com.aisip.OnO.backend.problem.repository; + +import java.time.LocalDate; + +/** + * 복습 대상 문제 조회 전용 프로젝션. + * + *

엔티티로 조회하면 {@code Problem.problemAnalysis} 가 mappedBy OneToOne 이라 + * 행마다 존재 여부를 확인하는 select 가 추가로 나간다. 응답에 필요한 값은 스칼라뿐이므로 + * 이 프로젝션으로 단일 쿼리로 읽는다. + */ +public record ReviewDueProblemProjection( + Long problemId, + String memo, + String reference, + LocalDate nextReviewAt, + int reviewInterval, + int consecutiveCorrectCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 823a47e4..5e0a9ff1 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -6,7 +6,9 @@ import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageType; @@ -28,6 +30,7 @@ import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection; import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveSummary; @@ -38,6 +41,8 @@ import com.aisip.OnO.backend.tag.exception.TagErrorCase; import com.aisip.OnO.backend.tag.repository.ProblemTagMappingRepository; import com.aisip.OnO.backend.tag.repository.TagRepository; +import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -48,7 +53,7 @@ import org.springframework.data.domain.PageRequest; import java.time.LocalDate; -import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; import java.util.EnumMap; @@ -67,6 +72,7 @@ public class ProblemService { private static final String AI_ANALYSIS_RATE_LIMIT_KEY = "ai_analysis"; private static final int AI_ANALYSIS_LIMIT_PER_DAY = 20; + private static final int MEMO_MAX_LENGTH = 1000; private final ProblemRepository problemRepository; @@ -79,6 +85,8 @@ public class ProblemService { private final MissionLogService missionLogService; + private final MissionProgressUpdater missionProgressUpdater; + private final ProblemAnalysisService analysisService; private final PracticeNoteRepository practiceNoteRepository; @@ -93,6 +101,7 @@ public class ProblemService { private final RateLimitService rateLimitService; private final StreakCacheService streakCacheService; private final ApplicationEventPublisher eventPublisher; + private final ProblemReviewReminderService reminderService; @Transactional(readOnly = true) public ProblemResponseDto findProblemForAdmin(Long problemId) { @@ -205,8 +214,15 @@ public Long findProblemCountByUser(Long userId) { @Transactional public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) { + validateProblemContent(problemRegisterDto.memo(), problemRegisterDto.reference()); + + // folderId 가 null 이면 findById(null) 이 InvalidDataAccessApiUsageException 을 던져 500 이 나간다. + // 입력 누락은 서버 오류가 아니므로 400 으로 거절한다. + if (problemRegisterDto.folderId() == null) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_FOLDER_ID_REQUIRED); + } - Folder folder = folderRepository.findById(problemRegisterDto.folderId()) + Folder folder = folderRepository.findByIdForShare(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); validateFolderOwner(folder, userId); @@ -219,8 +235,12 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( + new ProblemCreatedEvent.ProblemData(problem.getId(), problem.getMemo(), problem.getReference(), problem.getCreatedAt()) + ))); log.info("userId: {} register problemId: {}", userId, problem.getId()); @@ -233,6 +253,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) */ @Transactional public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long userId) { + validateProblemContent(problemRegisterV2Dto.memo(), problemRegisterV2Dto.reference()); Folder folder = resolveRegisterFolder(problemRegisterV2Dto.folderId(), userId); ProblemRegisterDto baseDto = new ProblemRegisterDto( @@ -278,8 +299,12 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( + new ProblemCreatedEvent.ProblemData(problem.getId(), problem.getMemo(), problem.getReference(), problem.getCreatedAt()) + ))); log.info("userId: {} register problem(v2) problemId: {}", userId, problem.getId()); return problem.getId(); @@ -296,6 +321,7 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 if (registerDtos == null || registerDtos.isEmpty()) { return List.of(); } + registerDtos.forEach(dto -> validateProblemContent(dto.memo(), dto.reference())); Map foldersById = resolveRegisterFolders(registerDtos, userId); Folder rootFolder = registerDtos.stream().anyMatch(dto -> dto.folderId() == null) @@ -342,12 +368,19 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 streakCacheService.evict(userId); missionLogService.registerProblemWriteMissionBatch(userId, problems.size()); + // 여러 장을 한 번에 등록하면 장수만큼 오른다. 기존 적립은 하루 3건에서 멈추지만 미션 진행도는 별개다. + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED, problems.size()); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", problems.size()))); List problemIds = problems.stream() .map(Problem::getId) .toList(); + + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, problems.stream() + .map(p -> new ProblemCreatedEvent.ProblemData(p.getId(), p.getMemo(), p.getReference(), p.getCreatedAt())) + .toList())); + log.info("userId: {} register problems(v2 batch) problemIds: {}", userId, problemIds); return problemIds; } @@ -357,7 +390,7 @@ private Folder resolveRegisterFolder(Long folderId, Long userId) { return resolveRootFolder(userId); } - return folderRepository.findById(folderId) + return folderRepository.findByIdForShare(folderId) .map(folder -> { validateFolderOwner(folder, userId); return folder; @@ -380,7 +413,7 @@ private Map resolveRegisterFolders(List regi return Map.of(); } - Map foldersById = folderRepository.findAllById(folderIds).stream() + Map foldersById = folderRepository.findAllByIdInForShare(folderIds).stream() .collect(Collectors.toMap(Folder::getId, folder -> folder)); if (foldersById.size() != folderIds.size()) { throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); @@ -512,7 +545,8 @@ public void analysisProblem(Long problemId, Long userId) { analysisProblemWithoutOwnerCheck(problemId, userId); } - @Transactional + // 호출자(analysisProblem)의 트랜잭션 안에서 실행된다. + // private 메서드에 @Transactional 을 붙여도 Spring 프록시가 가로채지 못해 아무 효과가 없다. private void analysisProblemWithoutOwnerCheck(Long problemId, Long userId) { // 이미 분석이 완료된 문제는 재요청하지 않음 if (problemAnalysisRepository.findByProblemId(problemId) @@ -555,11 +589,13 @@ public void updateProblemAnalysisToNoImage(Long problemId, Long userId) { @Transactional public void updateProblemInfo(ProblemRegisterDto problemRegisterDto, Long userId) { + validateProblemContent(problemRegisterDto.memo(), problemRegisterDto.reference()); Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); problem.updateProblem(problemRegisterDto); syncProblemTags(problem, userId, problemRegisterDto.tagIds()); + reminderService.refreshSnapshot(problem.getId(), problem.getMemo(), problem.getReference()); log.info("userId: {} update problemId: {}", userId, problem.getId()); } @@ -569,7 +605,7 @@ public void updateProblemFolder(ProblemRegisterDto problemRegisterDto, Long user Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); if (problemRegisterDto.folderId() != null) { - Folder folder = folderRepository.findById(problemRegisterDto.folderId()) + Folder folder = folderRepository.findByIdForShare(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); validateFolderOwner(folder, userId); @@ -684,7 +720,7 @@ private void syncProblemTags(Problem problem, Long userId, List requestedT * - S3 파일 삭제: 비동기 (RabbitMQ Producer로 전송) * - PracticeNote 매핑 삭제: 동기 (데이터 정합성) */ - @Transactional + // 호출자(deleteProblem / deleteFolderProblems / deleteAllUserProblems)의 트랜잭션 안에서 실행된다. private void deleteProblemWithoutOwnerCheck(Long problemId) { // 1. 이미지 데이터 조회 List imageDataList = problemImageDataRepository.findAllByProblemId(problemId); @@ -702,12 +738,15 @@ private void deleteProblemWithoutOwnerCheck(Long problemId) { // 4. PracticeNote 매핑 삭제 (동기 - 데이터 정합성 보장) practiceNoteRepository.deleteProblemFromAllPractice(problemId); - // 5. 문제 삭제 (Soft Delete) + // 5. 미발송 알림 예약 취소 + reminderService.cancelPendingByProblem(problemId); + + // 6. 문제 삭제 (Soft Delete) problemRepository.deleteById(problemId); log.info("problemId: {} DB 삭제 완료", problemId); - // 6. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) + // 7. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) imageDataList.forEach(imageData -> { try { s3DeleteProducer.sendDeleteMessage(imageData.getImageUrl(), problemId); @@ -744,7 +783,7 @@ public void deleteProblemList(Long userId, List problemIdList) { problemIdList.forEach(problemId -> deleteProblem(problemId, userId)); } - @Transactional + // 호출자(deleteAllByFolderIds)의 트랜잭션 안에서 실행된다. private void deleteFolderProblems(Long folderId) { problemRepository.findAllByFolderId(folderId) .forEach(problem -> { @@ -888,6 +927,19 @@ private List toProblemResponseDtos(List problems) { .collect(Collectors.toList()); } + /** + * 컬럼 길이를 넘는 입력은 DB에서 Data truncation 으로 500 이 나므로 저장 전에 400 으로 거절한다. + */ + private void validateProblemContent(String memo, String reference) { + if (memo != null && memo.length() > Problem.MEMO_MAX_LENGTH) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_MEMO_TOO_LONG); + } + + if (reference != null && reference.length() > Problem.REFERENCE_MAX_LENGTH) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_REFERENCE_TOO_LONG); + } + } + private void validateFolderOwner(Long folderId, Long userId) { Folder folder = folderRepository.findById(folderId) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); @@ -900,23 +952,28 @@ private void validateFolderOwner(Folder folder, Long userId) { } } + /** + * 오늘 복습 대상 문제 조회. + * + *

엔티티를 그대로 조회하면 {@code Problem.problemAnalysis} 가 mappedBy OneToOne 이라 + * 행마다 존재 여부 확인 쿼리가 한 번씩 더 나간다(N+1). 응답에 필요한 값은 스칼라뿐이므로 + * 프로젝션으로 한 번에 읽는다. + */ @Transactional(readOnly = true) public ReviewDueResponseDto getReviewDueProblems(Long userId) { LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); - List dueProblems = problemRepository.findReviewDueProblems(userId, today); + List dueProblems = problemRepository.findReviewDueProblems(userId, today); long overdueCount = dueProblems.stream() - .filter(p -> p.getNextReviewAt().isBefore(today)) + .filter(p -> p.nextReviewAt().isBefore(today)) .count(); - List problemDtos = dueProblems.stream() - .map(ReviewDueResponseDto.ReviewDueProblemDto::from) - .collect(Collectors.toList()); - return ReviewDueResponseDto.builder() .dueCount(dueProblems.size()) .overdueCount(overdueCount) - .problems(problemDtos) + .problems(dueProblems.stream() + .map(ReviewDueResponseDto.ReviewDueProblemDto::from) + .toList()) .build(); } } diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java index 1e296aae..9eb54d17 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java @@ -12,6 +12,7 @@ public record ProblemSolveRegisterDto( AnswerStatus answerStatus, String reflection, List improvements, - Integer timeSpentSeconds + Integer timeSpentSeconds, + String moodEmojiKey ) { } \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java index 078a47c3..c322711b 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java @@ -3,7 +3,6 @@ import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; import com.aisip.OnO.backend.problemsolve.entity.ImprovementType; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Builder; @@ -18,17 +17,15 @@ public record ProblemSolveResponseDto( Long problemSolveId, Long problemId, Long userId, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime practicedAt, AnswerStatus answerStatus, String reflection, List improvements, Integer timeSpentSeconds, + String moodEmojiKey, Boolean migratedFromLegacy, List imageUrls, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt ) { private static final ObjectMapper objectMapper = new ObjectMapper(); @@ -56,6 +53,7 @@ public static ProblemSolveResponseDto from(ProblemSolve problemSolve) { .reflection(problemSolve.getReflection()) .improvements(improvementList) .timeSpentSeconds(problemSolve.getTimeSpentSeconds()) + .moodEmojiKey(problemSolve.getMoodEmojiKey()) .migratedFromLegacy(problemSolve.getMigratedFromLegacy()) .imageUrls(problemSolve.getImages().stream() .sorted((i1, i2) -> i1.getImageOrder().compareTo(i2.getImageOrder())) diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java index 1fcea56e..2025c174 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java @@ -10,6 +10,7 @@ public record ProblemSolveUpdateDto( AnswerStatus answerStatus, String reflection, List improvements, - Integer timeSpentSeconds + Integer timeSpentSeconds, + String moodEmojiKey ) { } \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java index 9888daa9..5c6fe0f1 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java @@ -49,6 +49,10 @@ public class ProblemSolve extends BaseEntity { private Integer timeSpentSeconds; + // 이번 복습이 어땠는지 남기는 이모지. CustomEmojiValidator 화이트리스트 키이고, 안 고를 수도 있어 nullable 이다. + @Column(name = "mood_emoji_key", length = 80) + private String moodEmojiKey; + @Column(nullable = false) @Builder.Default private Boolean migratedFromLegacy = false; @@ -59,7 +63,7 @@ public class ProblemSolve extends BaseEntity { public static ProblemSolve create(Problem problem, Long userId, LocalDateTime practicedAt, AnswerStatus answerStatus, String reflection, String improvements, - Integer timeSpentSeconds) { + Integer timeSpentSeconds, String moodEmojiKey) { return ProblemSolve.builder() .problem(problem) .userId(userId) @@ -68,6 +72,7 @@ public static ProblemSolve create(Problem problem, Long userId, LocalDateTime pr .reflection(reflection) .improvements(improvements) .timeSpentSeconds(timeSpentSeconds) + .moodEmojiKey(moodEmojiKey) .migratedFromLegacy(false) .build(); } @@ -88,10 +93,11 @@ public void addImage(ProblemSolveImageData image) { } public void updateSolve(AnswerStatus answerStatus, String reflection, String improvements, - Integer timeSpentSeconds) { + Integer timeSpentSeconds, String moodEmojiKey) { this.answerStatus = answerStatus; this.reflection = reflection; this.improvements = improvements; this.timeSpentSeconds = timeSpentSeconds; + this.moodEmojiKey = moodEmojiKey; } } diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java index 1f941ca3..dbf4f7ce 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java @@ -9,7 +9,12 @@ public enum ProblemSolveErrorCase implements ErrorCase { PROBLEM_SOLVE_NOT_FOUND(404, 4021, "복습 기록을 찾을 수 없습니다."), - PROBLEM_SOLVE_USER_UNMATCHED(403, 4022, "해당 복습 기록에 대한 권한이 없습니다."); + PROBLEM_SOLVE_USER_UNMATCHED(403, 4022, "해당 복습 기록에 대한 권한이 없습니다."), + + /** + * 필수 입력이 빠진 요청. 예전에는 그대로 NPE / 제약조건 위반으로 터져 500 이 나갔다. + */ + PROBLEM_SOLVE_INVALID_INPUT(400, 4023, "복습 기록 요청 값이 올바르지 않습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java new file mode 100644 index 00000000..ffd50a9e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.problemsolve.repository; + +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; + +import java.time.LocalDateTime; + +/** + * 복습 기록 한 건에서 판정에 필요한 값만 뽑은 것. + * + *

엔티티를 그대로 읽지 않는다. {@code reflection} 과 {@code improvements} 가 TEXT 라 + * 복습 기록이 수천 건인 사용자의 것을 통째로 올리면 본문만 메가바이트 단위가 된다. + * 회고는 "비어 있지 않은가" 만 보면 되므로 길이만 가져온다. + * + * @param reflectionLength 공백을 걷어낸 회고의 길이. 회고가 없으면 {@code null} 이다. + */ +public record ProblemSolveMark( + Long problemId, + LocalDateTime practicedAt, + AnswerStatus answerStatus, + Integer reflectionLength +) { + + /** 회고를 남긴 기록인지. 공백만 적은 것은 남긴 것으로 치지 않는다. */ + public boolean hasReflection() { + return reflectionLength != null && reflectionLength > 0; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java index 17cf086a..342e1e2f 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java @@ -50,5 +50,27 @@ public interface ProblemSolveRepository extends JpaRepository findSolveSummariesByProblemIds(@Param("problemIds") Collection problemIds); + /** + * 훈장 판정용. 한 사용자의 복습 기록을 복습 시각 순서로 한 번에 훑는다. + * + *

훈장 여섯 개(집념·불사조·새벽반·올빼미·무결점·회고왕)가 전부 이 표를 본다. 조건마다 따로 세면 + * 훈장 화면을 열 때마다 같은 표를 여섯 번 훑게 되는데, 한 번 읽어 자바에서 여섯 값을 함께 만들면 + * 쿼리가 하나다. 무결점(연속 정답)과 불사조(오답 뒤 정답)는 애초에 순서를 봐야 해서 + * 집계 함수로는 안 되고, 그 둘이 이미 순서대로 훑기를 요구하므로 나머지도 같은 훑기에 얹는다. + * + *

복습 시각이 같은 기록이 있을 수 있어 id 로 한 번 더 정렬한다. 기준이 없으면 연속 판정이 + * 같은 데이터에서도 호출마다 달라진다. + * + *

{@code idx_problem_solve_user_practiced_at (user_id, practiced_at)} 이 그대로 쓰인다. + */ + @Query(""" + SELECT new com.aisip.OnO.backend.problemsolve.repository.ProblemSolveMark( + pr.problem.id, pr.practicedAt, pr.answerStatus, LENGTH(TRIM(pr.reflection))) + FROM ProblemSolve pr + WHERE pr.userId = :userId + ORDER BY pr.practicedAt ASC, pr.id ASC + """) + List findAllMarksByUserId(@Param("userId") Long userId); + void deleteAllByProblemId(Long problemId); } diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index 1519e386..490abc2c 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -1,12 +1,17 @@ package com.aisip.OnO.backend.problemsolve.service; +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.util.redis.StreakCacheService; import com.aisip.OnO.backend.problem.service.ReviewIntervalCalculator; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveUpdateDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolveImageData; import com.aisip.OnO.backend.problemsolve.exception.ProblemSolveErrorCase; @@ -27,6 +32,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -45,11 +52,14 @@ public class ProblemSolveService { private final ProblemSolveImageDataRepository problemSolveImageDataRepository; private final ProblemRepository problemRepository; private final MissionLogService missionLogService; + private final MissionProgressUpdater missionProgressUpdater; private final FileUploadService fileUploadService; private final S3DeleteProducer s3DeleteProducer; private final ObjectMapper objectMapper; private final StreakCacheService streakCacheService; private final ApplicationEventPublisher eventPublisher; + private final ProblemReviewReminderService reminderService; + private final CustomEmojiValidator customEmojiValidator; @Qualifier("s3UploadExecutor") private final Executor s3UploadExecutor; @@ -101,6 +111,12 @@ public List getUserProblemSolves(Long userId) { @Transactional public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { + // problemId 가 없으면 findById(null) 이 InvalidDataAccessApiUsageException 으로 터져 500 이 나간다. + // answerStatus 가 없으면 복습 주기 계산에서 NPE, 저장 시 not-null 위반으로 역시 500 이다. + if (dto == null || dto.problemId() == null || dto.answerStatus() == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + Problem problem = problemRepository.findById(dto.problemId()) .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); @@ -108,6 +124,9 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { throw new ApplicationException(ProblemErrorCase.PROBLEM_USER_UNMATCHED); } + // 이모지는 선택 사항이라 null 은 허용하고, 값이 있으면 화이트리스트에 있는 키인지만 본다. + customEmojiValidator.validateNullable(dto.moodEmojiKey()); + // improvements를 JSON 문자열로 변환 String improvementsJson = null; if (dto.improvements() != null && !dto.improvements().isEmpty()) { @@ -119,19 +138,29 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { } } + // practicedAt 은 not-null 컬럼이다. 앱이 값을 안 보내면 "지금 푼 것"으로 본다. + LocalDateTime practicedAt = dto.practicedAt() != null + ? dto.practicedAt() + : LocalDateTime.now(ZoneId.of("Asia/Seoul")); + ProblemSolve problemSolve = ProblemSolve.create( problem, userId, - dto.practicedAt(), + practicedAt, dto.answerStatus(), dto.reflection(), improvementsJson, - dto.timeSpentSeconds() + dto.timeSpentSeconds(), + dto.moodEmojiKey() ); problemSolveRepository.save(problemSolve); streakCacheService.evict(userId); missionLogService.registerProblemPracticeMission(userId, problem.getId()); + missionProgressUpdater.increase(userId, MissionMetric.SOLVE_RECORDED); + if (dto.answerStatus() == AnswerStatus.CORRECT) { + missionProgressUpdater.increase(userId, MissionMetric.SOLVE_CORRECT); + } ReviewIntervalCalculator.ReviewSchedule schedule = ReviewIntervalCalculator.calculate( dto.answerStatus(), @@ -142,6 +171,8 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PRACTICE_COMPLETED, java.util.Map.of())); + reminderService.skipDuePendingByProblemSolve(userId, dto.problemId(), practicedAt); + log.info("userId: {} created problem solve: {}, nextReviewAt: {}, mastered: {}", userId, problemSolve.getId(), schedule.nextReviewAt(), schedule.isMastered()); @@ -150,6 +181,10 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { @Transactional public void uploadProblemSolveImages(Long problemSolveId, Long userId, List images) { + if (images == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(problemSolveId) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); @@ -182,6 +217,10 @@ public void uploadProblemSolveImages(Long problemSolveId, Long userId, List imageUrls) { + if (imageUrls == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(problemSolveId) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); if (!Objects.equals(problemSolve.getUserId(), userId)) { @@ -200,6 +239,12 @@ public void addImageUrls(Long problemSolveId, Long userId, List imageUrl @Transactional public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { + // problemSolveId 가 없으면 findById(null) 이 500 으로, answerStatus 가 없으면 + // not-null 컬럼 위반으로 500 이 난다. 둘 다 클라이언트 입력 오류다. + if (dto == null || dto.problemSolveId() == null || dto.answerStatus() == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(dto.problemSolveId()) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); @@ -207,6 +252,8 @@ public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_USER_UNMATCHED); } + customEmojiValidator.validateNullable(dto.moodEmojiKey()); + // improvements를 JSON 문자열로 변환 String improvementsJson = null; if (dto.improvements() != null && !dto.improvements().isEmpty()) { @@ -222,7 +269,8 @@ public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { dto.answerStatus(), dto.reflection(), improvementsJson, - dto.timeSpentSeconds() + dto.timeSpentSeconds(), + dto.moodEmojiKey() ); log.info("userId: {} updated problem solve: {}", userId, problemSolve.getId()); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java index 18739ecf..490fbd6e 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java @@ -83,7 +83,8 @@ public record ChallengeResponse(Long challengeId, String title, String type, Str Integer periodDays, Integer targetValue, LocalDateTime startAt, LocalDateTime endAt, String status, List memberProgress, - Integer groupCurrent) { + Integer groupCurrent, + Long createdByUserId, boolean canDelete) { } public record ChallengeMemberProgressResponse(Long userId, String name, String profileImageUrl, int current, boolean cleared) { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java index 2a35e04a..68ad06a6 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java @@ -24,6 +24,16 @@ public class StudyRoomChallenge extends BaseEntity { @JoinColumn(name = "room_id", nullable = false) private StudyRoom room; + /** + * 챌린지를 만든 사용자. + * + *

{@code StudyRoom.hostUserId} 와 같이 FK 없는 식별자로 둔다. 사용자 탈퇴는 소프트 삭제라 + * 행이 남지만, 방장 식별자와 같은 방식을 쓰는 편이 이 도메인 안에서 일관된다. + * 기존 행은 V46 마이그레이션에서 방장으로 채웠다(그때까지 방장만 지울 수 있었다). + */ + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + @Column(nullable = false, length = 40) private String title; @@ -58,12 +68,14 @@ public class StudyRoomChallenge extends BaseEntity { @Column(name = "completed_at") private LocalDateTime completedAt; - public static StudyRoomChallenge create(StudyRoom room, String title, StudyRoomChallengeType type, + public static StudyRoomChallenge create(StudyRoom room, Long createdByUserId, String title, + StudyRoomChallengeType type, StudyRoomChallengeMetric metric, StudyRoomChallengePeriod period, Integer periodDays, Integer targetValue, LocalDateTime startAt, LocalDateTime endAt) { return StudyRoomChallenge.builder() .room(room) + .createdByUserId(createdByUserId) .title(title) .type(type) .metric(metric) @@ -76,7 +88,26 @@ public static StudyRoomChallenge create(StudyRoom room, String title, StudyRoomC .build(); } + /** 이 챌린지를 만든 사용자인지 확인한다. */ + public boolean isCreatedBy(Long userId) { + return userId != null && userId.equals(createdByUserId); + } + public void updateStatus(StudyRoomChallengeStatus status) { this.status = status; } + + /** + * 완료 상태로 전이하면서 완료 시각까지 함께 채운다. + * + *

완료 전이는 중복 알림을 막으려고 {@code tryTransitionFromInProgress} 벌크 UPDATE 로 + * 먼저 DB 를 바꾼다. 벌크 UPDATE 는 영속성 컨텍스트를 우회하므로, 이어서 + * {@code updateStatus} 만 호출하면 커밋 시점의 더티 체킹 UPDATE 가 메모리에 남아 있던 + * {@code completedAt = null} 을 그대로 덮어써 완료 시각이 사라진다. + * 두 필드를 함께 맞춰 두어야 벌크 UPDATE 결과가 유지된다. + */ + public void markCompleted(LocalDateTime completedAt) { + this.status = StudyRoomChallengeStatus.COMPLETED; + this.completedAt = completedAt; + } } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java index 3526e538..361f9816 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java @@ -18,11 +18,12 @@ public enum StudyRoomErrorCase implements ErrorCase { ALREADY_MEMBER(409, 10008, "이미 참여 중인 스터디룸입니다."), CHALLENGE_NOT_FOUND(404, 10009, "챌린지를 찾을 수 없습니다."), CHALLENGE_LIMIT_EXCEEDED(409, 10010, "진행 중인 챌린지 수를 초과했습니다."), - SESSION_ALREADY_ACTIVE(409, 10011, "이미 진행 중인 공부 세션이 있습니다."), - SESSION_NOT_FOUND(404, 10012, "공부 세션을 찾을 수 없습니다."), + // 삭제된 번호 10011, 10012, 10015 는 다른 의미로 재사용하지 않는다. + // 운영 중인 4.0.0 앱이 이 번호를 옛 문구로 매핑하고 있다 (OnO_FRONT lib/Util/ErrorMessageMapper.dart:120-129). + // 10011, 10012 는 공부 세션 기능과 함께 지웠다. SHARED_PROBLEM_NOT_FOUND(404, 10013, "공유 문제를 찾을 수 없습니다."), REPORT_NOT_FOUND(404, 10014, "주간 리포트를 찾을 수 없습니다."), - INVALID_REACTION_EMOJI(400, 10015, "허용되지 않은 반응입니다."), + // 10015 는 리액션 이모지 검증이 CustomEmojiErrorCase.INVALID_EMOJI_KEY(11001) 로 옮겨가 지웠다. 재사용 금지. INVALID_STUDY_ROOM_REQUEST(400, 10016, "스터디룸 요청 값이 올바르지 않습니다."), SHARED_PROBLEM_COMMENT_NOT_FOUND(404, 10017, "공유 문제 댓글을 찾을 수 없습니다."), INVALID_SHARED_PROBLEM_COMMENT(400, 10018, "공유 문제 댓글 내용이 올바르지 않습니다."), diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java index 4eb761e7..3d0a033a 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.studyroom.quartz; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -37,7 +38,7 @@ public void execute(JobExecutionContext context) { } NotificationRequestDto dto = new NotificationRequestDto(null, title, body, - Map.of("type", "CHALLENGE_NOTIFICATION", "roomId", String.valueOf(roomId))); + Map.of("type", NotificationType.CHALLENGE_NOTIFICATION, "roomId", String.valueOf(roomId))); memberRepository.findAllWithUserByRoomId(roomId).forEach(member -> { try { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java index 5f3b15f5..c8c0b3ce 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java @@ -6,7 +6,6 @@ import org.quartz.*; import org.springframework.stereotype.Component; -import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; @@ -20,21 +19,18 @@ public class ChallengeNotificationScheduler { private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); private final Scheduler scheduler; + private final ChallengeNotificationTimePolicy timePolicy; public void scheduleNotifications(StudyRoomChallenge challenge) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZONE); LocalDateTime startAt = challenge.getStartAt(); LocalDateTime endAt = challenge.getEndAt(); - LocalDateTime halfwayAt = startAt.plus(Duration.between(startAt, endAt).dividedBy(2)); - if (halfwayAt.isAfter(now)) { - schedule(challenge, "HALFWAY", halfwayAt); - } + timePolicy.resolveHalfwayFireAt(startAt, endAt, now) + .ifPresent(fireAt -> schedule(challenge, "HALFWAY", fireAt)); - LocalDateTime oneDayBeforeAt = endAt.minusDays(1); - if (oneDayBeforeAt.isAfter(now)) { - schedule(challenge, "ONE_DAY_LEFT", oneDayBeforeAt); - } + timePolicy.resolveOneDayLeftFireAt(startAt, endAt, now) + .ifPresent(fireAt -> schedule(challenge, "ONE_DAY_LEFT", fireAt)); } public void cancelNotifications(Long challengeId) { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java new file mode 100644 index 00000000..ddb079f4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java @@ -0,0 +1,89 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Optional; + +/** + * 챌린지 알림을 언제 보낼지 정한다. + * + *

계산된 시각을 그대로 쓰면 한밤중에 푸시가 나간다. 앱이 마감을 그날 23:59:59 로 보내서 + * D-1 알림은 거의 항상 밤 23:59 였고, 중간 알림은 챌린지를 만든 시각을 그대로 따라가 새벽에 걸렸다. + * 그래서 발송 시각은 아침 09:00 과 저녁 18:00 두 기준 시각으로만 맞춘다. + * + *

복습 리마인더의 {@code ProblemReviewReminderPolicy} 는 09~21시 "구간" 안이면 그대로 두지만, + * 챌린지 알림은 방 멤버 전원에게 한 번에 나가는 알림이라 시각을 두 지점으로 못박는 편이 예측하기 쉽다. + */ +@Component +public class ChallengeNotificationTimePolicy { + + private static final LocalTime MORNING = LocalTime.of(9, 0); + private static final LocalTime EVENING = LocalTime.of(18, 0); + + /** + * 중간 알림 발송 시각. 기준은 기존과 같이 시작과 마감의 중간 지점이다. + */ + public Optional resolveHalfwayFireAt(LocalDateTime startAt, LocalDateTime endAt, LocalDateTime now) { + LocalDateTime halfwayAt = startAt.plus(Duration.between(startAt, endAt).dividedBy(2)); + return resolveFireAt(halfwayAt, now, endAt); + } + + /** + * D-1 알림 발송 시각. 기준은 기존과 같이 마감 하루 전이다. + * + *

기간이 하루 이하인 챌린지는 마감 하루 전이 시작 시각보다도 이르다. 그 시점을 억지로 미루면 + * 중간 알림과 같은 시각에 두 번 나가므로 아예 보내지 않는다. + */ + public Optional resolveOneDayLeftFireAt(LocalDateTime startAt, LocalDateTime endAt, LocalDateTime now) { + LocalDateTime oneDayBeforeAt = endAt.minusDays(1); + if (oneDayBeforeAt.isBefore(startAt)) { + return Optional.empty(); + } + return resolveFireAt(oneDayBeforeAt, now, endAt); + } + + /** + * 계산된 시각을 기준 시각(09:00 / 18:00)으로 맞춘다. + * + *

같은 날 두 기준 시각 중 가까운 쪽으로 옮기고(23:59 면 그날 18:00, 새벽 3시면 그날 09:00), + * 그 시각이 이미 지났으면 다음 기준 시각으로 넘긴다. 마감을 넘기면 보내지 않는다. + * 마감이 지난 뒤에 오는 "내일 마감" 알림은 의미가 없기 때문이다. + * + * @return 보낼 시각, 보낼 수 없으면 {@link Optional#empty()} + */ + private Optional resolveFireAt(LocalDateTime candidate, LocalDateTime now, LocalDateTime deadline) { + LocalDateTime fireAt = snapToNearestAnchor(candidate); + while (!fireAt.isAfter(now) && !fireAt.isAfter(deadline)) { + fireAt = nextAnchor(fireAt); + } + if (!fireAt.isAfter(now) || fireAt.isAfter(deadline)) { + return Optional.empty(); + } + return Optional.of(fireAt); + } + + /** + * 같은 날 09:00 과 18:00 중 가까운 쪽. 정확히 가운데(13:30)면 이른 쪽으로 보낸다. + */ + private LocalDateTime snapToNearestAnchor(LocalDateTime candidate) { + LocalDateTime morning = candidate.toLocalDate().atTime(MORNING); + LocalDateTime evening = candidate.toLocalDate().atTime(EVENING); + long toMorning = Math.abs(Duration.between(morning, candidate).toSeconds()); + long toEvening = Math.abs(Duration.between(evening, candidate).toSeconds()); + return toEvening < toMorning ? evening : morning; + } + + private LocalDateTime nextAnchor(LocalDateTime anchor) { + LocalTime time = anchor.toLocalTime(); + if (time.isBefore(MORNING)) { + return anchor.toLocalDate().atTime(MORNING); + } + if (time.isBefore(EVENING)) { + return anchor.toLocalDate().atTime(EVENING); + } + return anchor.toLocalDate().plusDays(1).atTime(MORNING); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java index 481efa2c..72fdbc51 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java @@ -1,27 +1,57 @@ package com.aisip.OnO.backend.studyroom.quartz; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.quartz.*; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; -@Configuration +import java.util.TimeZone; + +/** + * 스터디룸 주간 리포트 잡을 스케줄러에 등록한다. + * + *

예전에는 JobDetail 과 Trigger 를 {@code @Bean} 으로 선언만 했다. {@code @Bean} 을 모아 등록하는 건 + * Spring Boot 자동 구성의 스케줄러인데, {@code QuartzConfig} 가 SchedulerFactoryBean 을 직접 만들어 + * 자동 구성이 물러나 한 번도 등록되지 않았다. 다른 스케줄러와 같은 방식으로 직접 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor public class StudyRoomScheduler { - @Bean - public JobDetail studyRoomWeeklyReportJobDetail() { - return JobBuilder.newJob(StudyRoomWeeklyReportJob.class) - .withIdentity("studyRoomWeeklyReportJob") - .storeDurably() - .build(); - } + // 이름은 기존 @Bean 선언과 같게 두고 그룹은 지정하지 않는다(DEFAULT). + private static final String JOB_NAME = "studyRoomWeeklyReportJob"; + private static final String TRIGGER_NAME = "studyRoomWeeklyReportTrigger"; + private static final String CRON = "0 0 8 ? * MON"; - @Bean - public Trigger studyRoomWeeklyReportTrigger() { - return TriggerBuilder.newTrigger() - .forJob(studyRoomWeeklyReportJobDetail()) - .withIdentity("studyRoomWeeklyReportTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0 8 ? * MON").inTimeZone(java.util.TimeZone.getTimeZone("Asia/Seoul"))) - .build(); - } + private final Scheduler scheduler; + + @PostConstruct + public void schedule() { + try { + JobDetail jobDetail = JobBuilder.newJob(StudyRoomWeeklyReportJob.class) + .withIdentity(JOB_NAME) + .storeDurably() + .build(); + Trigger trigger = TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(TRIGGER_NAME) + .withSchedule(CronScheduleBuilder.cronSchedule(CRON).inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))) + .build(); + + scheduler.addJob(jobDetail, true); + + if (scheduler.checkExists(trigger.getKey())) { + scheduler.rescheduleJob(trigger.getKey(), trigger); + } else { + scheduler.scheduleJob(trigger); + } + + log.info("[StudyRoomWeeklyReport] 주간 리포트 스케줄 등록 완료 - cron: {} (Asia/Seoul)", CRON); + } catch (SchedulerException e) { + log.error("[StudyRoomWeeklyReport] 주간 리포트 스케줄 등록 실패", e); + } + } } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java index d7affac2..42f683dd 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java @@ -17,4 +17,7 @@ public interface StudyRoomFeedReactionRepository extends JpaRepository findAllByFeedIds(@Param("feedIds") Collection feedIds); List findAllByFeedId(Long feedId); + + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java index a89fb012..a5d289f0 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java @@ -20,6 +20,9 @@ public interface StudyRoomSharedProblemCommentReactionRepository extends JpaRepo @Query("select r from StudyRoomSharedProblemCommentReaction r where r.comment.id in :commentIds") List findAllByCommentIds(@Param("commentIds") Collection commentIds); + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); + @Modifying @Query("delete from StudyRoomSharedProblemCommentReaction r where r.user.id = :userId") void deleteByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java index 31d83f99..959f2977 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java @@ -19,6 +19,9 @@ public interface StudyRoomSharedProblemReactionRepository extends JpaRepository< List findAllBySharedProblemId(Long sharedProblemId); + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); + @Modifying @Query("delete from StudyRoomSharedProblemReaction r where r.sharedProblem.id = :sharedProblemId") void deleteBySharedProblemId(@Param("sharedProblemId") Long sharedProblemId); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java index 0ee020b5..69e83d63 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java @@ -8,6 +8,7 @@ import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationScheduler; import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -44,8 +45,9 @@ public class StudyRoomChallengeService { public List getChallenges(Long roomId, Long userId) { accessService.validateMember(roomId, userId); List members = memberRepository.findAllWithUserByRoomId(roomId); + Viewer viewer = viewer(userId, members); return challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId).stream() - .map(challenge -> toResponse(challenge, members, true)) + .map(challenge -> toResponse(challenge, members, true, viewer)) .sorted(Comparator.comparing((ChallengeResponse r) -> "in_progress".equals(r.status()) ? 0 : 1) .thenComparing(Comparator.comparing(ChallengeResponse::endAt).reversed())) .toList(); @@ -66,6 +68,7 @@ public ChallengeResponse createChallenge(Long roomId, Long userId, ChallengeCrea StudyRoom room = accessService.getRoomOrThrow(roomId); StudyRoomChallenge challenge = challengeRepository.save(StudyRoomChallenge.create( room, + userId, request.title().trim(), parseEnum(request.type(), StudyRoomChallengeType.class), parseEnum(request.metric(), StudyRoomChallengeMetric.class), @@ -76,25 +79,51 @@ public ChallengeResponse createChallenge(Long roomId, Long userId, ChallengeCrea request.endAt() )); notificationScheduler.scheduleNotifications(challenge); - return toResponse(challenge, memberRepository.findAllWithUserByRoomId(roomId), true); + List members = memberRepository.findAllWithUserByRoomId(roomId); + return toResponse(challenge, members, true, viewer(userId, members)); } + /** + * 챌린지를 지운다. 방장은 방 안의 모든 챌린지를, 일반 멤버는 자기가 만든 챌린지만 지울 수 있다. + * + *

비멤버는 챌린지가 있는지조차 알려 주지 않으려고 멤버 검증을 먼저 한다(403 10002). + * 그 다음 챌린지를 찾고(404 10009), 마지막에 방장이거나 작성자인지를 본다(403 10003). + */ @Transactional public void deleteChallenge(Long roomId, Long challengeId, Long userId) { - accessService.validateHost(roomId, userId); + StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); StudyRoomChallenge challenge = challengeRepository.findByIdAndRoomId(challengeId, roomId) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.CHALLENGE_NOT_FOUND)); + if (!canDelete(challenge, member.getRole() == StudyRoomMemberRole.HOST, userId)) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_HOST_ONLY); + } notificationScheduler.cancelNotifications(challengeId); challengeRepository.delete(challenge); } + /** 이미 읽어 둔 멤버 목록에서 방장 여부를 뽑는다. 응답을 만들 때마다 멤버를 다시 조회하지 않는다. */ + private Viewer viewer(Long userId, List members) { + boolean isHost = members.stream() + .anyMatch(member -> member.getRole() == StudyRoomMemberRole.HOST + && userId.equals(member.getUser().getId())); + return new Viewer(userId, isHost); + } + + private boolean canDelete(StudyRoomChallenge challenge, boolean isHost, Long userId) { + return isHost || challenge.isCreatedBy(userId); + } + + /** 응답을 받아 보는 사용자. 삭제 버튼 노출 조건을 서버에서 계산하려고 들고 다닌다. */ + private record Viewer(Long userId, boolean isHost) {} + private void refreshRoomChallengeStatuses(Long roomId) { List members = memberRepository.findAllWithUserByRoomId(roomId); challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId) - .forEach(challenge -> toResponse(challenge, members, true)); + .forEach(challenge -> toResponse(challenge, members, true, null)); } - private ChallengeResponse toResponse(StudyRoomChallenge challenge, List members, boolean persistStatus) { + private ChallengeResponse toResponse(StudyRoomChallenge challenge, List members, + boolean persistStatus, Viewer viewer) { List userIds = members.stream().map(member -> member.getUser().getId()).toList(); AggregationRange range = resolveAggregationRange(challenge, LocalDateTime.now()); LocalDate streakBaseDate = min(LocalDate.now(), challenge.getEndAt().toLocalDate()); @@ -123,9 +152,12 @@ private ChallengeResponse toResponse(StudyRoomChallenge challenge, List 0) { - challenge.updateStatus(status); + // 벌크 UPDATE 가 채운 completedAt 을 엔티티에도 반영해야 한다. + // updateStatus 만 부르면 커밋 시 더티 체킹이 completedAt = null 로 되돌린다. + challenge.markCompleted(completedAt); // 트랜잭션 커밋 후 FCM 발송 — 롤백 시 중복 발송 방지 final StudyRoomChallenge committedChallenge = challenge; final List committedMembers = members; @@ -152,7 +184,9 @@ public void afterCommit() { challenge.getEndAt(), toApiValue(status.name()), memberProgress, - groupCurrent + groupCurrent, + challenge.getCreatedByUserId(), + viewer != null && canDelete(challenge, viewer.isHost(), viewer.userId()) ); } @@ -311,9 +345,10 @@ private void evaluateCompletionAndNotify( if (challenge.getStatus() != status) { if (status == StudyRoomChallengeStatus.COMPLETED) { - int updated = challengeRepository.tryTransitionFromInProgress(challenge.getId(), status, LocalDateTime.now()); + LocalDateTime completedAt = LocalDateTime.now(); + int updated = challengeRepository.tryTransitionFromInProgress(challenge.getId(), status, completedAt); if (updated > 0) { - challenge.updateStatus(status); + challenge.markCompleted(completedAt); final StudyRoomChallenge committedChallenge = challenge; final List committedMembers = members; TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @@ -339,7 +374,7 @@ private void notifyChallengeCompleted(StudyRoomChallenge challenge, List { try { fcmService.sendNotificationToAllUserDevice(member.getUser().getId(), dto); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java index 51921216..574815e5 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedService.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; @@ -67,13 +68,27 @@ public CursorPageResponse getFeed(Long roomId, Long userId, Lo return new CursorPageResponse<>(content, nextCursor, hasNext, safeSize); } - @Transactional + /** + * 리액션 토글. + * + *

"이미 눌렀는지 찾아보고 없으면 넣는" check-then-act 인데 테이블에는 + * {@code (대상, 사용자, 이모지)} 유니크 제약이 걸려 있다. 이모지를 연타해 같은 요청이 겹치면 + * 두 요청이 모두 "없음"을 읽고 INSERT 해 뒤엣것이 유니크 제약에 걸렸고, + * {@code DataIntegrityViolationException} 이 잡히지 않고 올라가 500 이 나갔다. + * + *

충돌은 같은 사용자끼리만 일어나므로(유니크 키에 user_id 가 들어간다) 사용자 행을 잠가 + * 그 사용자의 토글만 직렬화한다. 다른 사용자의 리액션은 서로 막지 않는다. + * 격리 수준을 READ COMMITTED 로 내리는 것도 함께 필요하다. REPEATABLE READ 에서는 + * 잠금을 얻기 전 조회들이 이미 스냅샷을 고정해, 잠금을 잡은 뒤의 중복 확인이 + * 앞 요청이 커밋한 리액션을 못 보기 때문이다. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) public FeedReactionToggleResponse toggleReaction(Long roomId, Long feedId, Long userId, ReactionToggleRequest request) { accessService.validateMember(roomId, userId); customEmojiValidator.validate(request.emoji()); StudyRoomFeed feed = feedRepository.findByIdAndRoomId(feedId, roomId) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_NOT_FOUND)); - User user = userRepository.findById(userId) + User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); reactionRepository.findByFeedIdAndUserIdAndEmoji(feedId, userId, request.emoji()) .ifPresentOrElse(reactionRepository::delete, diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java index 2d2136f6..b1808b97 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomInviteService.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -41,7 +42,22 @@ public InviteCodeResponse issueInviteCode(Long roomId, Long userId) { .orElseGet(() -> createInviteCode(roomId, now)); } - @Transactional + /** + * 초대 코드로 스터디룸에 참여한다. + * + *

격리 수준을 READ COMMITTED 로 낮춘 이유가 있다. MySQL 기본값인 REPEATABLE READ 에서는 + * 트랜잭션의 첫 일반 SELECT(여기서는 {@code findByCode}) 시점에 read view 가 고정되고, + * 그 뒤의 일반 SELECT 는 락을 잡은 뒤라도 계속 그 스냅샷을 본다. + * 그래서 {@code lockRoom} 으로 방 행을 잠가도 이어지는 {@code existsBy...}/{@code countBy...} 가 + * 먼저 커밋된 참여자를 보지 못했다. 실제로 8명이 마지막 한 자리에 동시에 들어오면 + * 여덟 명 전원이 "아직 19명"을 읽고 모두 입장해 정원 20명이 27명이 됐고, + * 같은 사용자가 같은 코드를 연타하면 유니크 제약에 걸려 500 이 나갔다. + * READ COMMITTED 는 문 단위로 스냅샷을 새로 뜨므로, 락을 얻은 뒤의 검사가 최신 상태를 본다. + * + *

락 순서는 항상 방 → 사용자다. 다른 경로({@code createRoom} 은 사용자만, + * {@code leaveRoom}/{@code deleteRoom} 은 방만)와 순서가 어긋나지 않아 교착이 생기지 않는다. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) public StudyRoomDetailResponse join(StudyRoomJoinRequest request, Long userId) { if (request.code() == null || !request.code().matches("\\d{6}")) { throw new ApplicationException(StudyRoomErrorCase.INVITE_CODE_INVALID); @@ -58,11 +74,14 @@ public StudyRoomDetailResponse join(StudyRoomJoinRequest request, Long userId) { if (memberRepository.countByRoomId(room.getId()) >= StudyRoomService.MAX_ROOM_MEMBER_COUNT) { throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_FULL); } + // 참여 방 수 상한은 방이 아니라 사용자 단위 제약이라 방 락으로는 지켜지지 않는다. + // 서로 다른 방에 동시에 들어가면 모두가 같은 개수를 읽고 통과하므로, + // 검사 전에 사용자 행을 잠가 같은 사용자의 참여 요청을 직렬화한다. + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); if (memberRepository.countByUserId(userId) >= StudyRoomService.MAX_USER_ROOM_COUNT) { throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_LIMIT_EXCEEDED); } - User user = userRepository.findByIdForUpdate(userId) - .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); room.addMember(StudyRoomMember.create(user, StudyRoomMemberRole.MEMBER)); return studyRoomService.buildDetail(room); } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java index 920be37f..e6cade67 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java @@ -23,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -144,7 +145,31 @@ public StudyRoom lockRoom(Long roomId) { @Transactional public void leaveRoom(Long roomId, Long userId) { StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); - if (member.getRole() != StudyRoomMemberRole.HOST) { + removeMembership(roomId, userId, member.getRole()); + } + + /** + * 회원 탈퇴 시 그 사용자가 속한 모든 방에서 직접 나간 것과 같게 처리한다. + * + *

{@code User} 는 소프트 삭제라 이 정리를 하지 않으면 멤버 행과 {@code host_user_id} 가 남는다. + * 방장이 탈퇴한 방은 방장 권한이 사라져 남은 멤버가 방을 관리할 수 없고, 탈퇴자 멤버 행은 + * 정원에도 계속 잡힌다. 사용자를 소프트 삭제하기 전에 같은 트랜잭션에서 호출해야 한다. + * + *

방장인 방은 행 잠금을 잡으므로 방 id 순으로 돌아 잠금 순서를 일정하게 둔다. + */ + @Transactional + public void leaveAllRoomsForWithdrawal(Long userId) { + memberRepository.findAllWithRoomByUserId(userId).stream() + .sorted(Comparator.comparing(member -> member.getRoom().getId())) + .forEach(member -> removeMembership(member.getRoom().getId(), userId, member.getRole())); + } + + /** + * 멤버십 하나를 정리한다. 방장이면 가장 먼저 들어온 멤버에게 방장을 넘기고, + * 남은 멤버가 없으면 방을 지운다. + */ + private void removeMembership(Long roomId, Long userId, StudyRoomMemberRole role) { + if (role != StudyRoomMemberRole.HOST) { memberRepository.deleteByRoomIdAndUserId(roomId, userId); return; } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java index 6340051d..dd2fea65 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java @@ -16,12 +16,14 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -89,7 +91,7 @@ public SharedProblemCommentResponse createComment(Long roomId, Long sharedProble fcmService.sendNotificationToAllUserDevice(sharerId, new NotificationRequestDto(null, "공유 문제에 댓글이 달렸어요", user.getName() + ": " + preview, - Map.of("type", "SHARED_PROBLEM_COMMENT", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId)))); + Map.of("type", NotificationType.SHARED_PROBLEM_COMMENT, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId)))); } catch (Exception e) { log.warn("댓글 알림 발송 실패 - userId: {}", sharerId, e); } @@ -109,13 +111,27 @@ public SharedProblemCommentResponse updateComment(Long roomId, Long sharedProble return toResponse(comment, commentReactionRepository.findAllByCommentId(commentId), userId, true); } - @Transactional + /** + * 리액션 토글. + * + *

"이미 눌렀는지 찾아보고 없으면 넣는" check-then-act 인데 테이블에는 + * {@code (대상, 사용자, 이모지)} 유니크 제약이 걸려 있다. 이모지를 연타해 같은 요청이 겹치면 + * 두 요청이 모두 "없음"을 읽고 INSERT 해 뒤엣것이 유니크 제약에 걸렸고, + * {@code DataIntegrityViolationException} 이 잡히지 않고 올라가 500 이 나갔다. + * + *

충돌은 같은 사용자끼리만 일어나므로(유니크 키에 user_id 가 들어간다) 사용자 행을 잠가 + * 그 사용자의 토글만 직렬화한다. 다른 사용자의 리액션은 서로 막지 않는다. + * 격리 수준을 READ COMMITTED 로 내리는 것도 함께 필요하다. REPEATABLE READ 에서는 + * 잠금을 얻기 전 조회들이 이미 스냅샷을 고정해, 잠금을 잡은 뒤의 중복 확인이 + * 앞 요청이 커밋한 리액션을 못 보기 때문이다. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) public SharedProblemCommentReactionToggleResponse toggleReaction(Long roomId, Long sharedProblemId, Long commentId, Long userId, ReactionToggleRequest request) { accessService.validateMember(roomId, userId); customEmojiValidator.validate(request.emoji()); StudyRoomSharedProblemComment comment = getCommentOrThrow(roomId, sharedProblemId, commentId); - User user = userRepository.findById(userId) + User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); commentReactionRepository.findByCommentIdAndUserIdAndEmoji(commentId, userId, request.emoji()) .ifPresentOrElse(commentReactionRepository::delete, @@ -155,10 +171,6 @@ private String validateContent(SharedProblemCommentRequest request) { return content.trim(); } - private SharedProblemCommentResponse toResponse(StudyRoomSharedProblemComment comment, Long userId) { - return toResponse(comment, List.of(), userId, false); - } - private SharedProblemCommentResponse toResponse(StudyRoomSharedProblemComment comment, List reactions, Long userId) { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java index 126b40b5..0a5456aa 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java @@ -15,6 +15,7 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -22,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -97,17 +99,31 @@ public SharedProblemResponse shareProblem(Long roomId, Long userId, SharedProble notifyRoomMembers(roomId, userId, user.getName() + "님이 문제를 공유했어요", referenceOrFallback(problem.getReference()), - Map.of("type", "SHARED_PROBLEM", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblem.getId()))); + Map.of("type", NotificationType.SHARED_PROBLEM, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblem.getId()))); return toResponse(sharedProblem, List.of(), 0L, userId); } - @Transactional + /** + * 리액션 토글. + * + *

"이미 눌렀는지 찾아보고 없으면 넣는" check-then-act 인데 테이블에는 + * {@code (대상, 사용자, 이모지)} 유니크 제약이 걸려 있다. 이모지를 연타해 같은 요청이 겹치면 + * 두 요청이 모두 "없음"을 읽고 INSERT 해 뒤엣것이 유니크 제약에 걸렸고, + * {@code DataIntegrityViolationException} 이 잡히지 않고 올라가 500 이 나갔다. + * + *

충돌은 같은 사용자끼리만 일어나므로(유니크 키에 user_id 가 들어간다) 사용자 행을 잠가 + * 그 사용자의 토글만 직렬화한다. 다른 사용자의 리액션은 서로 막지 않는다. + * 격리 수준을 READ COMMITTED 로 내리는 것도 함께 필요하다. REPEATABLE READ 에서는 + * 잠금을 얻기 전 조회들이 이미 스냅샷을 고정해, 잠금을 잡은 뒤의 중복 확인이 + * 앞 요청이 커밋한 리액션을 못 보기 때문이다. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) public SharedProblemReactionToggleResponse toggleReaction(Long roomId, Long sharedProblemId, Long userId, ReactionToggleRequest request) { accessService.validateMember(roomId, userId); customEmojiValidator.validate(request.emoji()); StudyRoomSharedProblem sharedProblem = sharedProblemRepository.findByIdAndRoomId(sharedProblemId, roomId) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.SHARED_PROBLEM_NOT_FOUND)); - User user = userRepository.findById(userId) + User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); boolean added; var existing = reactionRepository.findBySharedProblemIdAndUserIdAndEmoji(sharedProblemId, userId, request.emoji()); @@ -123,7 +139,7 @@ public SharedProblemReactionToggleResponse toggleReaction(Long roomId, Long shar notifyUser(sharerId, "공유 문제에 반응이 달렸어요", user.getName() + "님이 반응을 추가했어요.", - Map.of("type", "SHARED_PROBLEM_REACTION", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId))); + Map.of("type", NotificationType.SHARED_PROBLEM_REACTION, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId))); } return new SharedProblemReactionToggleResponse(sharedProblemId, reactionService.summarizeSharedProblemReactions(reactionRepository.findAllBySharedProblemId(sharedProblemId), userId)); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java index a8a915cc..6df45398 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportService.java @@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.time.DayOfWeek; @@ -53,14 +54,26 @@ public List getReports(Long roomId, Long userId, int limit return reports.stream().map(report -> toResponse(report, readReportIds.contains(report.getId()))).toList(); } - @Transactional + /** + * 주간 리포트를 읽음으로 표시한다. + * + *

읽음 기록을 찾아보고 없으면 넣는 check-then-act 이고, 테이블에는 + * {@code (report_id, user_id)} 유니크 제약이 있다. 리포트를 여는 순간 요청이 겹쳐 들어오면 + * 두 트랜잭션이 모두 "안 읽음"을 읽고 INSERT 해 뒤엣것이 유니크 제약에 걸렸고, + * 그 예외가 잡히지 않고 올라가 500 이 나갔다. + * + *

충돌은 같은 사용자끼리만 일어나므로 사용자 행을 잠가 그 사용자의 요청만 직렬화한다. + * REPEATABLE READ 에서는 잠금 이전 조회가 이미 스냅샷을 고정해 버려 잠근 뒤의 확인도 + * 앞 요청의 기록을 못 보므로, 격리 수준을 READ COMMITTED 로 함께 내린다. + */ + @Transactional(isolation = Isolation.READ_COMMITTED) public WeeklyReportReadResponse markRead(Long roomId, Long reportId, Long userId) { accessService.validateMember(roomId, userId); StudyRoomWeeklyReport report = reportRepository.findByIdAndRoomId(reportId, roomId) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.REPORT_NOT_FOUND)); + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); if (readRepository.findByReportIdAndUserId(reportId, userId).isEmpty()) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); readRepository.save(StudyRoomWeeklyReportRead.create(report, user, LocalDateTime.now())); } return new WeeklyReportReadResponse(reportId, true); @@ -84,13 +97,6 @@ public void createPreviousWeekReports() { } } - void createReportIfAbsent(StudyRoom room, LocalDate weekStart, LocalDate weekEnd, LocalDateTime start, LocalDateTime end) { - List members = memberRepository.findAllWithUserByRoomId(room.getId()); - List userIds = members.stream().map(member -> member.getUser().getId()).toList(); - Map stats = statsService.getStats(userIds, start, end, weekEnd); - createReportIfAbsent(room, members, stats, weekStart, weekEnd, 0); - } - private void createReportsForBatch(List rooms, LocalDate weekStart, LocalDate weekEnd, LocalDateTime start, LocalDateTime end) { List roomIds = rooms.stream().map(StudyRoom::getId).toList(); @@ -167,7 +173,7 @@ private void refreshChallengeStatuses(List roomIds, Map { List findAllByUserIdOrderByNameAsc(Long userId); List findAllByIdInAndUserId(List ids, Long userId); + + /** + * 소프트 삭제된 동명 태그를 되살린다. + * + *

{@code idx_tag_user_normalized} 는 {@code deleted_at} 을 포함하지 않아 + * 삭제된 행도 이름을 계속 점유한다. 그래서 "삭제 후 같은 이름으로 재생성"이 + * Duplicate entry 로 실패하는데, 새로 넣는 대신 기존 행을 복구해 이를 피한다. + * + *

{@code @SQLRestriction("deleted_at IS NULL")} 때문에 JPQL 로는 삭제된 행에 + * 접근할 수 없어 네이티브 쿼리를 쓴다. + * + * @return 복구된 행 수 (0 이면 되살릴 태그가 없었다는 뜻) + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "UPDATE tag SET deleted_at = NULL, name = :name, updated_at = NOW(6) " + + "WHERE user_id = :userId AND normalized_name = :normalizedName AND deleted_at IS NOT NULL", + nativeQuery = true) + int restoreDeletedTag(@Param("userId") Long userId, + @Param("name") String name, + @Param("normalizedName") String normalizedName); } diff --git a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java index 6e3dbc9c..3f277109 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -15,7 +15,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; @@ -40,21 +42,43 @@ public class TagService { private final TagRepository tagRepository; private final ProblemTagMappingRepository problemTagMappingRepository; + private final TagWriter tagWriter; private final RedisSingleDataService redisSingleDataService; private final ObjectMapper objectMapper; + /** + * 태그를 만들거나, 이미 같은 이름이 있으면 그 태그를 그대로 돌려준다. + * + *

여기서 트랜잭션을 열지 않는 이유가 있다. 삽입은 (user_id, normalized_name) + * 유니크 인덱스와 경쟁하는데, 실패한 트랜잭션 안에서는 재조회로 복구할 수 없다. + * 조회/삽입을 각각 독립 트랜잭션으로 수행하는 {@link TagWriter} 에 맡기고, + * 여기서는 실패 시 재조회 판단만 한다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public TagResponseDto createTag(Long userId, TagCreateRequestDto requestDto) { - String tagName = normalizeDisplayName(requestDto.name()); + String tagName = normalizeDisplayName(requestDto == null ? null : requestDto.name()); String normalizedName = tagName.toLowerCase(Locale.ROOT); - Tag tag = tagRepository.findByUserIdAndNormalizedName(userId, normalizedName) - .orElseGet(() -> tagRepository.save(Tag.from(userId, tagName, normalizedName))); + Tag tag = tagWriter.findOrRestore(userId, tagName, normalizedName) + .orElseGet(() -> insertOrFindConcurrentlyCreated(userId, tagName, normalizedName)); evictTagCache(userId); log.info("userId: {} create tag: {}", userId, tag.getName()); return TagResponseDto.from(tag); } + private Tag insertOrFindConcurrentlyCreated(Long userId, String tagName, String normalizedName) { + try { + return tagWriter.insert(userId, tagName, normalizedName); + } catch (DataIntegrityViolationException e) { + // 같은 이름을 동시에 보낸 다른 요청이 먼저 커밋했다는 뜻이다. + // 삽입 트랜잭션은 이미 롤백됐으므로, 새 트랜잭션에서 그 태그를 찾아 돌려준다. + log.info("userId: {} tag insert lost the race, re-reading: {}", userId, tagName); + return tagWriter.findOrRestore(userId, tagName, normalizedName) + .orElseThrow(() -> e); + } + } + @Transactional(readOnly = true) public List getUserTags(Long userId) { String cacheKey = TAG_LIST_CACHE_PREFIX + userId; @@ -78,7 +102,7 @@ public void deleteTag(Long userId, Long tagId) { } public void deleteTags(Long userId, TagDeleteRequestDto requestDto) { - Set tagIds = toDistinctIds(requestDto.deleteTagIdList()); + Set tagIds = toDistinctIds(requestDto == null ? null : requestDto.deleteTagIdList()); if (tagIds.isEmpty()) { throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); } @@ -165,8 +189,16 @@ private void writeTagCache(String key, List tags) { } } + /** + * 캐시 무효화 실패가 태그 생성/삭제 자체를 실패시키면 안 된다. + * Redis 가 죽어도 DB 반영은 이미 끝났고, 조회는 캐시 미스로 DB 를 타면 된다. + */ private void evictTagCache(Long userId) { - redisSingleDataService.deleteSingleData(TAG_LIST_CACHE_PREFIX + userId); + try { + redisSingleDataService.deleteSingleData(TAG_LIST_CACHE_PREFIX + userId); + } catch (Exception e) { + log.warn("Failed to evict tag list cache. userId={}, reason={}", userId, e.getMessage()); + } } private String normalizeDisplayName(String rawTagName) { diff --git a/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java new file mode 100644 index 00000000..6122d60d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.tag.service; + +import com.aisip.OnO.backend.tag.entity.Tag; +import com.aisip.OnO.backend.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 태그 한 건의 조회/삽입을 각각 독립된 트랜잭션으로 수행한다. + * + *

태그 생성은 (user_id, normalized_name) 유니크 인덱스와 경쟁하는 작업이라 + * "조회 → 없으면 삽입"이 한 트랜잭션 안에서는 안전하게 재시도되지 않는다. + * 삽입이 Duplicate entry 로 실패하는 순간 그 트랜잭션은 롤백 대상이 되고, + * MySQL REPEATABLE READ 에서는 같은 트랜잭션으로 다시 조회해도 + * 스냅샷 때문에 남이 커밋한 행이 보이지 않기 때문이다. + * + *

따라서 재시도 판단은 {@link TagService} 가 트랜잭션 바깥에서 하고, + * 여기서는 매 호출마다 새 트랜잭션(= 새 스냅샷)을 연다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TagWriter { + + private final TagRepository tagRepository; + + /** + * 살아있는 태그를 찾고, 없으면 소프트 삭제된 동명 태그를 되살린다. + * + *

{@code tag} 의 유니크 인덱스는 {@code deleted_at} 을 포함하지 않는다. + * 즉 소프트 삭제된 행도 이름을 계속 점유하므로, 삭제한 태그와 같은 이름을 + * 다시 만들면 Duplicate entry 로 실패한다. 사용자 입장에서 "지웠다가 다시 만들기"는 + * 당연히 되어야 하는 동작이라 삭제된 행을 복구해서 돌려준다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional findOrRestore(Long userId, String displayName, String normalizedName) { + Optional activeTag = tagRepository.findByUserIdAndNormalizedName(userId, normalizedName); + if (activeTag.isPresent()) { + return activeTag; + } + + int restored = tagRepository.restoreDeletedTag(userId, displayName, normalizedName); + if (restored == 0) { + return Optional.empty(); + } + + log.info("userId: {} restored soft-deleted tag: {}", userId, displayName); + return tagRepository.findByUserIdAndNormalizedName(userId, normalizedName); + } + + /** + * 태그를 새로 삽입한다. 유니크 인덱스에 걸리면 + * {@link org.springframework.dao.DataIntegrityViolationException} 이 그대로 올라간다. + * 호출자가 이 트랜잭션 밖에서 재조회로 복구한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Tag insert(Long userId, String displayName, String normalizedName) { + return tagRepository.saveAndFlush(Tag.from(userId, displayName, normalizedName)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index acdee406..b9e14e78 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -24,10 +24,35 @@ public record UserResponseDto ( Long totalStudyLevel, Long totalStudyCurrentPoint, Long totalStudyNextLevelThreshold, + // 응답에 없으면 프론트(UserInfoModel.dart)가 기본값 true 로 복원해, + // 알림을 꺼도 앱을 다시 켜면 스위치가 켜진 것처럼 보였다 + boolean notificationEnabled, LocalDateTime createdAt, LocalDateTime updatedAt ) { - private static final Long MAX_LEVEL = 15L; + /** + * 화면에 보여줄 개별 능력치 레벨의 상한. + * + *

15 로 두는 동안 도메인 쪽에는 상한이 아예 없어서, 내부 레벨은 무한정 오르고 여기서만 잘렸다. + * 앱에 보이는 레벨과 실제 레벨이 갈라진 것이다. 이제 도메인이 직접 멈추고 여기는 그 값을 따라간다. + * 숫자를 다시 적지 않고 {@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_ABILITY_LEVEL} + * 을 그대로 참조해, 한쪽만 고쳐서 다시 어긋나는 일이 없게 한다. + * + *

치장 해금표의 능력치별 아이템은 여전히 레벨 15 가 마지막이다. 16~20 은 해금 보상 없이 + * 게이지만 계속 오르는 구간이다. + */ + private static final Long MAX_ABILITY_LEVEL = + com.aisip.OnO.backend.mission.entity.UserMissionStatus.MAX_ABILITY_LEVEL; + + /** + * 화면에 보여줄 총 학습 레벨의 상한. + * + *

{@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_TOTAL_STUDY_LEVEL} 과 같은 값이어야 해서 + * 역시 그 상수를 직접 참조한다. 여기가 낮으면 그 위 레벨인 사용자에게 앱은 계속 낮은 Lv 를 보여주고 + * 게이지도 멈춘 것처럼 보이는데, 정작 치장은 총 학습 레벨 16·18·19·20 에서 계속 열린다. + */ + private static final Long MAX_TOTAL_STUDY_LEVEL = + com.aisip.OnO.backend.mission.entity.UserMissionStatus.MAX_TOTAL_STUDY_LEVEL; public static UserResponseDto from(@NotNull User user) { var missionStatus = user.getUserMissionStatus(); @@ -46,38 +71,48 @@ public static UserResponseDto from(@NotNull User user) { .notePracticeLevel(getResponseLevel(missionStatus.getNotePracticeLevel())) .notePracticePoint(getResponsePoint(missionStatus.getNotePracticeLevel(), missionStatus.getNotePracticePoint())) // DB에 저장된 총 학습 레벨 정보 사용 (계산 불필요) - .totalStudyLevel(getResponseLevel(missionStatus.getTotalStudyLevel())) + .totalStudyLevel(getTotalStudyResponseLevel(missionStatus.getTotalStudyLevel())) .totalStudyCurrentPoint(getTotalStudyResponsePoint(missionStatus.getTotalStudyLevel(), missionStatus.getTotalStudyPoint())) .totalStudyNextLevelThreshold(getTotalStudyNextLevelThreshold(missionStatus)) + .notificationEnabled(user.isNotificationEnabled()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); } private static Long getResponseLevel(Long level) { - if (level > MAX_LEVEL) { - return MAX_LEVEL; + if (level > MAX_ABILITY_LEVEL) { + return MAX_ABILITY_LEVEL; } return level; } private static Long getResponsePoint(Long level, Long point) { - if (level > MAX_LEVEL) { - return getThresholdForLevel(MAX_LEVEL); + if (level > MAX_ABILITY_LEVEL) { + return getThresholdForLevel(MAX_ABILITY_LEVEL); } - return point; + // 상한에 닿은 뒤에도 포인트는 계속 쌓인다. 그대로 내보내면 게이지 분모를 넘겨 칸이 넘친다. + // 상한 미만에서는 잔여 포인트가 항상 임계값보다 작아 이 클램프가 걸리지 않는다. + return Math.min(point, getThresholdForLevel(level)); + } + + private static Long getTotalStudyResponseLevel(Long level) { + if (level > MAX_TOTAL_STUDY_LEVEL) { + return MAX_TOTAL_STUDY_LEVEL; + } + return level; } private static Long getTotalStudyResponsePoint(Long level, Long point) { - if (level > MAX_LEVEL) { - return getTotalStudyThresholdForLevel(MAX_LEVEL); + if (level > MAX_TOTAL_STUDY_LEVEL) { + return getTotalStudyThresholdForLevel(MAX_TOTAL_STUDY_LEVEL); } - return point; + return Math.min(point, getTotalStudyThresholdForLevel(level)); } private static Long getTotalStudyNextLevelThreshold(com.aisip.OnO.backend.mission.entity.UserMissionStatus status) { - if (status.getTotalStudyLevel() >= MAX_LEVEL) { - return getTotalStudyThresholdForLevel(MAX_LEVEL); + if (status.getTotalStudyLevel() >= MAX_TOTAL_STUDY_LEVEL) { + return getTotalStudyThresholdForLevel(MAX_TOTAL_STUDY_LEVEL); } // 개별 능력치 필요 경험치 × 4 return getTotalStudyThresholdForLevel(status.getTotalStudyLevel()); diff --git a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java index 7e11eba3..e0f601e0 100644 --- a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java @@ -8,7 +8,13 @@ @RequiredArgsConstructor public enum UserErrorCase implements ErrorCase { - USER_NOT_FOUND(404, 3001, "사용자를 찾을 수 없습니다."); + USER_NOT_FOUND(404, 3001, "사용자를 찾을 수 없습니다."), + + /** + * 소셜 로그인 요청에 identifier 가 없거나, 암호화 후 저장 한계를 넘는 경우. + * identifier 없이 가입시키면 다음 로그인에서 같은 계정을 찾지 못해 계정이 계속 늘어난다. + */ + INVALID_USER_IDENTIFIER(400, 3002, "유효하지 않은 사용자 식별자입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserRegistrationWriter.java b/src/main/java/com/aisip/OnO/backend/user/service/UserRegistrationWriter.java new file mode 100644 index 00000000..7712bcae --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserRegistrationWriter.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.user.service; + +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 회원 계정 한 건의 조회/생성을 각각 독립된 트랜잭션으로 수행한다. + * + *

{@code user.identifier} 에는 유니크 인덱스가 걸려 있어 "조회 → 없으면 생성"이 + * 한 트랜잭션 안에서는 안전하게 재시도되지 않는다. 삽입이 Duplicate entry 로 실패하는 순간 + * 그 트랜잭션은 롤백 대상이 되고, MySQL REPEATABLE READ 에서는 같은 트랜잭션으로 다시 조회해도 + * 스냅샷 때문에 남이 커밋한 행이 보이지 않는다. 게다가 로그인 진입점인 + * {@code UserAuthService} 자체가 트랜잭션이라 재조회도 그 스냅샷에 갇힌다. + * + *

그래서 재시도 판단은 {@link UserService} 가 하고, 여기서는 호출마다 새 트랜잭션 + * (= 새 스냅샷)을 연다. 태그 중복 생성 장애에서 쓴 {@code TagWriter} 와 같은 구조다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserRegistrationWriter { + + private final UserRepository userRepository; + private final FolderService folderService; + private final PracticeNoteService practiceNoteService; + + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public Optional findByIdentifier(String identifier) { + return userRepository.findByIdentifier(identifier); + } + + /** + * 계정과 초기 데이터(기본 폴더, 기본 복습노트)를 한 트랜잭션으로 만든다. + * + *

유니크 인덱스에 걸리면 {@link org.springframework.dao.DataIntegrityViolationException} + * 이 그대로 올라간다. 호출자가 이 트랜잭션 밖에서 재조회로 복구한다. + * 계정만 남고 초기 데이터가 없는 어중간한 상태가 생기지 않도록 셋을 같은 트랜잭션에 둔다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public User create(UserRegisterDto userRegisterDto) { + User user = userRepository.saveAndFlush(User.from(userRegisterDto)); + folderService.initializeDefaultFoldersIfAbsent(user.getId()); + practiceNoteService.registerDefaultPractice(user.getId()); + return user; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index e4c5e0c1..aac4900b 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -1,23 +1,29 @@ package com.aisip.OnO.backend.user.service; import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.auth.service.JwtTokenService; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -45,24 +51,41 @@ public class UserService { private static final long MAX_PROFILE_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + /** + * identifier 는 CryptoConverter 로 암호화(Base64)돼 varchar(255) 컬럼에 저장된다. + * 평문 176자부터 암호문이 256자가 되어 저장 자체가 실패하므로 그 앞에서 400 으로 끊는다. + */ + private static final int MAX_IDENTIFIER_LENGTH = 175; + private final UserRepository userRepository; + private final UserRegistrationWriter registrationWriter; + private final FolderService folderService; private final ProblemService problemService; private final PracticeNoteService practiceNoteService; + private final ProblemReviewReminderService reminderService; + private final StudyRoomSharedProblemCommentRepository sharedProblemCommentRepository; private final StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; + private final StudyRoomService studyRoomService; + private final FileUploadService fileUploadService; private final S3DeleteProducer s3DeleteProducer; private final DiscordWebhookNotificationService discordWebhookNotificationService; + private final FcmTokenRepository fcmTokenRepository; + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenService jwtTokenService; + private User findUserEntity(Long userId){ return userRepository.findById(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); @@ -98,18 +121,50 @@ public UserResponseDto registerGuestUser() { return UserResponseDto.from(user); } - @Transactional + /** + * 소셜 로그인. 처음이면 계정을 만들고, 이미 있으면 그 계정을 돌려준다. + * + *

identifier 로 찾아보고 없으면 만드는 check-then-act 인데 identifier 에는 + * 유니크 인덱스가 걸려 있다. 앱이 실행 직후 로그인 요청을 겹쳐 보내면 두 요청이 모두 + * "없음"을 읽고 INSERT 해 뒤엣것이 Duplicate entry 로 500 이 됐다. 하필 로그인이라 + * 사용자는 앱에 들어오지 못한다. + * + *

조회와 생성을 {@link UserRegistrationWriter} 의 독립 트랜잭션으로 나눠, + * 경쟁에서 진 요청이 새 스냅샷으로 다시 조회해 먼저 만들어진 계정을 돌려주도록 한다. + */ public UserResponseDto registerMemberUser(UserRegisterDto userRegisterDto) { - return userRepository.findByIdentifier(userRegisterDto.identifier()) + validateIdentifier(userRegisterDto.identifier()); + + return registrationWriter.findByIdentifier(userRegisterDto.identifier()) .map(UserResponseDto::from) - .orElseGet(() -> { - User user = User.from(userRegisterDto); - userRepository.save(user); - folderService.initializeDefaultFoldersIfAbsent(user.getId()); - practiceNoteService.registerDefaultPractice(user.getId()); - discordWebhookNotificationService.sendMessage("새로운 멤버 유저가 가입했습니다!", "Username: " + userRegisterDto.name()); - return UserResponseDto.from(user); - }); + .orElseGet(() -> createMemberOrFindConcurrentlyCreated(userRegisterDto)); + } + + private UserResponseDto createMemberOrFindConcurrentlyCreated(UserRegisterDto userRegisterDto) { + try { + User user = registrationWriter.create(userRegisterDto); + discordWebhookNotificationService.sendMessage("새로운 멤버 유저가 가입했습니다!", "Username: " + userRegisterDto.name()); + return UserResponseDto.from(user); + } catch (DataIntegrityViolationException e) { + // 같은 identifier 로 동시에 들어온 다른 요청이 먼저 커밋했다는 뜻이다. + // 생성 트랜잭션은 이미 롤백됐으므로, 새 트랜잭션에서 그 계정을 찾아 돌려준다. + log.info("member insert lost the race, re-reading by identifier"); + return registrationWriter.findByIdentifier(userRegisterDto.identifier()) + .map(UserResponseDto::from) + .orElseThrow(() -> e); + } + } + + /** + * 소셜 로그인 식별자 검증. + * + *

identifier 가 비어 있으면 findByIdentifier 가 항상 빈 결과라 로그인할 때마다 + * 새 계정이 생기고, 사용자는 이전 오답노트로 돌아갈 수 없다. + */ + private void validateIdentifier(String identifier) { + if (identifier == null || identifier.isBlank() || identifier.length() > MAX_IDENTIFIER_LENGTH) { + throw new ApplicationException(UserErrorCase.INVALID_USER_IDENTIFIER); + } } @Transactional(readOnly = true) @@ -204,9 +259,16 @@ public void deleteUserById(Long userId) { sharedProblemCommentReactionRepository.deleteByCommentAuthorId(userId); sharedProblemCommentReactionRepository.deleteByUserId(userId); sharedProblemCommentRepository.deleteByAuthorId(userId); + studyRoomService.leaveAllRoomsForWithdrawal(userId); practiceNoteService.deleteAllPracticesByUser(userId); + reminderService.cancelAllByUser(userId); problemService.deleteAllUserProblems(userId); folderService.deleteAllUserFolders(userId); + // 탈퇴 계정 앞으로 발송이 생기면 그 기기를 이어 쓰는 사람에게 알림이 뜬다. 토큰 행을 남기지 않는다. + fcmTokenRepository.deleteAllByUserId(userId); + // 세션 행이 남으면 다른 기기가 갱신에 성공해 유령 로그인 상태가 된다. + // 갱신 요청과 같은 행을 다투므로 잠금 구간을 짧게 두려고 무거운 정리를 모두 끝낸 뒤에 지운다. + int deletedSessions = refreshTokenRepository.deleteByUserId(userId); user.maskIdentifierForDeletion(makeDeletedIdentifier(userId)); userRepository.flush(); @@ -214,7 +276,27 @@ public void deleteUserById(Long userId) { userRepository.deleteById(userId); userRepository.flush(); - log.info("userId: {} has deleted", userId); + log.info("userId: {} has deleted, refresh session rows removed: {}", userId, deletedSessions); + // 탈퇴해도 이미 나간 액세스 토큰은 만료 전(최대 30분)까지 살아 있다. 그 사이에 FCM 토큰 등록처럼 + // 사용자 존재를 확인하지 않는 요청이 들어오면 방금 지운 fcm_token 행이 되살아난다. (#300) + blacklistAccessTokensAfterCommit(userId); + } + + /** + * 커밋 이후에 막는다. 삭제가 롤백됐는데 토큰을 먼저 막아 버리면 + * 계정은 멀쩡한데 재로그인으로도 못 푸는 사용자가 생긴다. + */ + private void blacklistAccessTokensAfterCommit(Long userId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + jwtTokenService.blacklistUserAccessTokens(userId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + jwtTokenService.blacklistUserAccessTokens(userId); + } + }); } private String makeDeletedIdentifier(Long userId) { diff --git a/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java b/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java index 8a2b1f97..43c4524e 100644 --- a/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java +++ b/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java @@ -1,9 +1,14 @@ package com.aisip.OnO.backend.util.ai; +import com.aisip.OnO.backend.common.exception.HandledFailure; + /** * 재시도로 해결되지 않는 분석 실패(예: 이미지 판독 불가) 예외 + * + * ProblemAnalysisConsumer 가 잡아서 재큐잉 없이 ACK 하고, 상태도 FAILED 로 정리한다. + * 즉 여기까지가 정상 처리 경로라 HandledFailure 를 붙여 error 로그 대상에서 뺀다. */ -public class NonRetryableAnalysisException extends RuntimeException { +public class NonRetryableAnalysisException extends RuntimeException implements HandledFailure { public NonRetryableAnalysisException(String message) { super(message); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java b/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java new file mode 100644 index 00000000..9ac82983 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java @@ -0,0 +1,46 @@ +package com.aisip.OnO.backend.util.fcm; + +/** + * 푸시 알림 데이터의 {@code type} 값. + * + *

앱은 {@code data["type"]} 하나만 보고 어떤 화면을 열지 고른다. 값이 빠지거나 오타가 나면 + * 알림을 눌러도 아무 화면이 열리지 않으므로, 발송하는 쪽에서 문자열을 직접 쓰지 말고 여기 상수를 쓴다. + * + *

표기는 소문자 스네이크로 통일한다. 값은 앱과 맞춰 둔 계약이라 + * 바꾸려면 프론트 분기도 같이 움직여야 한다. + */ +public final class NotificationType { + + /** 오늘 복습할 문제가 있는 사용자에게 보내는 일일 알림. */ + public static final String REVIEW_DUE = "review_due"; + + /** 며칠 접속하지 않은 사용자에게 보내는 재참여 알림. */ + public static final String REENGAGEMENT = "reengagement"; + + /** 30일 넘게 접속하지 않은 사용자에게 월 1회 보내는 재참여 알림. */ + public static final String REENGAGEMENT_MONTHLY = "reengagement_monthly"; + + /** 문제 단위 복습 리마인더. */ + public static final String PROBLEM_REVIEW_REMINDER = "problem_review_reminder"; + + /** 사용자가 복습노트에 직접 걸어 둔 반복 알림. */ + public static final String PRACTICE_NOTE_REMINDER = "practice_note_reminder"; + + /** 챌린지 중간/마감 하루 전 알림. */ + public static final String CHALLENGE_NOTIFICATION = "challenge_notification"; + + /** 챌린지 달성 알림. */ + public static final String CHALLENGE_COMPLETED = "challenge_completed"; + + /** 스터디룸에 문제가 새로 공유됐을 때. */ + public static final String SHARED_PROBLEM = "shared_problem"; + + /** 내가 공유한 문제에 반응이 달렸을 때. */ + public static final String SHARED_PROBLEM_REACTION = "shared_problem_reaction"; + + /** 내가 공유한 문제에 댓글이 달렸을 때. */ + public static final String SHARED_PROBLEM_COMMENT = "shared_problem_comment"; + + private NotificationType() { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java b/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java index dcfe5d4e..530092d1 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/controller/FcmController.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,10 +24,11 @@ public class FcmController { private final FcmService fcmService; @PostMapping("/token") - public CommonResponse registerFcmToken(@RequestBody FcmTokenRequestDto fcmTokenRequestDto) { + public CommonResponse registerFcmToken(@Valid @RequestBody FcmTokenRequestDto fcmTokenRequestDto) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + // token 은 @NotBlank 로 이미 걸러졌으므로 null 방어가 필요 없다. log.info("FCM token registration requested - userId: {}, tokenLength: {}", - userId, fcmTokenRequestDto.token() == null ? 0 : fcmTokenRequestDto.token().length()); + userId, fcmTokenRequestDto.token().length()); fcmService.registerToken(fcmTokenRequestDto, userId); return CommonResponse.success("문제가 등록되었습니다."); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/dto/FcmTokenRequestDto.java b/src/main/java/com/aisip/OnO/backend/util/fcm/dto/FcmTokenRequestDto.java index 5ada7c09..edce04e8 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/dto/FcmTokenRequestDto.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/dto/FcmTokenRequestDto.java @@ -1,6 +1,19 @@ package com.aisip.OnO.backend.util.fcm.dto; -public record FcmTokenRequestDto ( +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * FCM 토큰 등록 요청. + * + *

예전에는 검증이 전혀 없어서 null 토큰은 {@code fcm_token.token} 의 NOT NULL 제약에 걸려 + * DataIntegrityViolationException 으로, 255자를 넘는 토큰은 컬럼 길이 초과로 각각 DB 까지 간 뒤에야 + * 거절됐다. 빈 문자열은 아예 걸러지지 않아 푸시를 보낼 수 없는 쓰레기 행이 그대로 쌓였다. + * 입력 검증은 DB 가 아니라 여기서 끝낸다. + */ +public record FcmTokenRequestDto( + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 255, message = "FCM 토큰 길이가 허용 범위를 초과했습니다.") String token ){ } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java b/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java index 5705212d..b66cc7e9 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java @@ -13,6 +13,11 @@ @Table(name = "fcm_token", uniqueConstraints = { @UniqueConstraint(name = "idx_fcm_token_user_token", columnNames = {"user_id", "token"}) + }, + // 유니크 인덱스는 선두가 user_id 라 token 단독 조회가 타지 못한다. + // 등록 때마다 이전 소유자 행을 token 으로 찾으므로 보조 인덱스를 따로 둔다. (운영 반영은 V44) + indexes = { + @Index(name = "idx_fcm_token_token", columnList = "token") } ) public class FcmToken extends BaseEntity { diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java index 31e7007e..3acca5f5 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java @@ -2,6 +2,9 @@ import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -17,6 +20,18 @@ public interface FcmTokenRepository extends JpaRepository { List findAllByUserId(Long userId); + /** 같은 기기 토큰이 다른 사용자에게 묶여 있는 행. 계정 전환 시 지울 대상이다. */ + List findAllByTokenAndUserIdNot(String token, Long userId); + + /** + * 탈퇴한 사용자의 기기 토큰을 지운다. (user_id, token) 유니크 인덱스의 선두 컬럼을 탄다. + * 영속성 컨텍스트를 비우지 않는다. 탈퇴 경로가 아직 사용자 엔티티를 수정 중이라 비우면 그 변경이 사라진다. + */ + @Modifying + @Transactional + @Query("DELETE FROM FcmToken f WHERE f.userId = :userId") + int deleteAllByUserId(@Param("userId") Long userId); + @Transactional void deleteByToken(String token); } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index 7220f1ea..8db9df48 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -14,10 +14,13 @@ import com.google.firebase.messaging.Notification; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @@ -25,21 +28,74 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional public class FcmService { private final FcmTokenRepository fcmTokenRepository; + private final FcmTokenWriter fcmTokenWriter; private final FirebaseMessaging firebaseMessaging; private final MeterRegistry meterRegistry; private final FcmNotificationProducer fcmNotificationProducer; + /** + * 토큰 등록을 최대 몇 번까지 시도하는가. + * + *

중복 키든 교착이든 상대가 이미 끝났거나 되돌아간 뒤에 다시 하는 것이라 대개 두 번째에 끝난다. + * 세 번째는 바로 그 사이에 또 다른 요청이 끼어든 경우를 위한 여유 한 번이다. + */ + private static final int MAX_REGISTER_ATTEMPTS = 3; + + /** + * 토큰은 기기 하나를 가리키므로 한 사용자에게만 묶여 있어야 한다. + * + *

같은 기기에서 A 가 로그아웃하고 B 가 로그인하면 같은 토큰으로 등록이 들어온다. + * 이때 (A, 토큰) 행을 남겨 두면 A 앞으로 가는 알림(댓글 작성자 이름, 미리보기 포함)이 B 기기에 뜬다. + * 클라이언트가 로그아웃 때 토큰을 해제하지 않으므로, 서버가 등록 시점에 이전 소유자 행을 지운다. + * 실제 조회·삭제·삽입은 {@link FcmTokenWriter} 가 독립 트랜잭션으로 수행한다. + * + *

여기서 트랜잭션을 열지 않는 이유가 있다. 삽입은 (user_id, token) 유니크 인덱스와 경쟁하는데, + * 실패한 트랜잭션 안에서는 재조회로 복구할 수 없다. 앱이 로그인 직후 등록과 토큰 갱신 등록을 겹쳐 보내면 + * 두 요청이 모두 "없음" 을 읽고 INSERT 해 뒤엣것이 Duplicate entry 로 떨어졌고, 운영에서 500 이 나갔다. + * (Sentry JAVA-SPRING-BOOT-5J, JAVA-SPRING-BOOT-5H) + * + *

같은 토큰을 한 번 더 등록하는 것은 결과 상태가 같은 재시도다. 그래서 중복은 실패가 아니라 성공으로 본다. + * 다만 롤백된 트랜잭션은 이전 소유자 행 삭제까지 되돌리므로, 새 트랜잭션에서 한 번 더 등록을 돌려 맞춘다. + * + *

경합이 중복 키로만 끝나는 것이 아니다. 같은 키를 동시에 INSERT 하면 InnoDB 가 중복 여부를 보려고 + * 상대가 쥔 레코드에 공유 잠금을 걸고 기다리는데, 그 상태에서 서로 삽입을 마치려 하면 교착이 된다. + * 이때는 {@code Duplicate entry} 가 아니라 {@link PessimisticLockingFailureException} + * (MySQL 교착과 잠금 대기 초과)으로 올라와 예전 코드의 중복 처리에 걸리지 않고 그대로 500 이 됐다. + * 전체 테스트를 돌릴 때 {@code FcmTokenRegistrationConcurrencyTest} 가 INSERT 교착으로 한 번 깨진 것이 그 경로다. + * (Sentry JAVA-SPRING-BOOT-5J 후속) + * + *

둘 다 "다시 하면 되는" 실패라 같은 방식으로 다룬다. 등록은 결과가 같은 작업이라 여러 번 돌려도 안전하다. + * 대신 {@link #MAX_REGISTER_ATTEMPTS} 번까지만 시도한다. 무한 재시도는 DB 가 아픈 상황에서 + * 요청 스레드를 붙잡아 장애를 키운다. 그래도 안 되면 원하는 상태가 되었는지 확인하고, + * 아니면 마지막 예외를 그대로 올려 500 과 Sentry 로 드러나게 둔다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void registerToken(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { - if(!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())){ - FcmToken fcmToken = FcmToken.From(fcmTokenRequestDto, userId); - fcmTokenRepository.save(fcmToken); + RuntimeException lastFailure = null; + + for (int attempt = 1; attempt <= MAX_REGISTER_ATTEMPTS; attempt++) { + try { + fcmTokenWriter.register(fcmTokenRequestDto, userId); + return; + } catch (DataIntegrityViolationException | PessimisticLockingFailureException failure) { + lastFailure = failure; + log.info("FCM token registration lost the race - userId: {}, attempt: {}/{}, reason: {}", + userId, attempt, MAX_REGISTER_ATTEMPTS, failure.getClass().getSimpleName()); + } } + + // 마지막 시도까지 실패했어도 다른 요청이 같은 행을 넣었으면 원하는 상태다. 그때는 성공으로 본다. + if (fcmTokenWriter.exists(userId, fcmTokenRequestDto.token())) { + log.info("FCM token already registered by a concurrent request - userId: {}", userId); + return; + } + + throw lastFailure; } public FcmTokenResponseDto findToken(String token) { @@ -80,6 +136,9 @@ public void sendNotification(NotificationRequestDto dto) { * 사용자의 모든 디바이스로 푸시 알림 전송 (RabbitMQ 비동기 방식) * - 기존 동기 방식에서 RabbitMQ 비동기 방식으로 변경 * - Quartz Job이나 API에서 호출 시 즉시 반환 + * - 큐 적재에 실패하면 예외를 그대로 던진다. 리마인더 발송기가 이 예외로 FAILED 를 기록한다. + * - 트랜잭션을 걸지 않는다. 트랜잭션 경계 안에서 적재 예외가 나면 호출자가 try/catch 로 삼켜도 + * 바깥 트랜잭션이 rollback-only 로 표시돼, 공유·반응·댓글 저장까지 되돌아가고 500 이 나갔다. */ public void sendNotificationToAllUserDevice(Long userId, NotificationRequestDto notificationRequestDto) { // RabbitMQ Producer로 메시지 전송 (비동기) diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java new file mode 100644 index 00000000..9296936e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.util.fcm.service; + +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 토큰 한 건의 등록을 독립된 트랜잭션으로 수행한다. + * + *

등록은 (user_id, token) 유니크 인덱스와 경쟁하는 작업이라 + * "조회 → 없으면 삽입"이 한 트랜잭션 안에서는 안전하게 복구되지 않는다. + * 삽입이 Duplicate entry 로 실패하는 순간 그 트랜잭션은 롤백 대상이 되고, + * 그 안에서 예외를 잡아도 rollback-only 로 표시돼 커밋 시점에 터진다. + * MySQL REPEATABLE READ 에서는 같은 트랜잭션으로 다시 조회해도 + * 스냅샷 때문에 남이 커밋한 행이 보이지 않는다. + * + *

그래서 중복 판단은 {@link FcmService} 가 트랜잭션 바깥에서 하고, + * 여기서는 매 호출마다 새 트랜잭션(= 새 스냅샷)을 연다. 태그 생성의 {@code TagWriter} 와 같은 구조다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmTokenWriter { + + private final FcmTokenRepository fcmTokenRepository; + + /** + * 이전 소유자 행을 지우고, 없으면 토큰 행을 새로 넣는다. + * + *

유니크 인덱스에 걸리면 {@link org.springframework.dao.DataIntegrityViolationException} 이 + * 이 트랜잭션 밖으로 그대로 올라간다. 호출자가 새 트랜잭션에서 복구한다. + * 이때 이 트랜잭션이 통째로 롤백되므로 이전 소유자 행 삭제도 함께 되돌아가는데, + * 삭제를 되살리는 것은 호출자의 재시도 몫이다. + * + *

{@code saveAndFlush} 로 삽입을 트랜잭션 안에서 앞당긴다. 커밋 시점 flush 까지 미루면 + * 실패 지점이 트랜잭션 경계와 겹쳐, 같은 메서드 안에서 실패 여부를 판단할 수 없다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void register(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { + List previousOwnerTokens = + fcmTokenRepository.findAllByTokenAndUserIdNot(fcmTokenRequestDto.token(), userId); + if (!previousOwnerTokens.isEmpty()) { + fcmTokenRepository.deleteAllInBatch(previousOwnerTokens); + log.info("FCM token moved to another user - userId: {}, removedPreviousOwnerRows: {}", + userId, previousOwnerTokens.size()); + } + + if (!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())) { + fcmTokenRepository.saveAndFlush(FcmToken.From(fcmTokenRequestDto, userId)); + } + } + + /** 새 트랜잭션에서 읽는다. 실패한 트랜잭션의 스냅샷으로는 남이 커밋한 행이 보이지 않기 때문이다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public boolean exists(Long userId, String token) { + return fcmTokenRepository.existsByUserIdAndToken(userId, token); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java index 3d6fd507..959c9e46 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.util.fileupload.controller; import com.aisip.OnO.backend.common.ratelimit.RateLimit; +import com.aisip.OnO.backend.common.ratelimit.RateLimitScope; import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.util.fileupload.dto.PresignedUrlResponse; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; @@ -43,7 +44,7 @@ public CommonResponse> uploadMultipleImageFiles( // 앱이 S3에 직접 업로드할 수 있는 Presigned URL 발급 // count: 발급할 URL 수 (최대 20) // contentType: 업로드할 이미지 MIME 타입 (예: image/jpeg) - @RateLimit(key = "presigned_url", limitPerDay = 200) + @RateLimit(key = "presigned_url", limitPerDay = 200, scope = RateLimitScope.FILE_UPLOAD) @GetMapping("/presigned-urls") public CommonResponse> getPresignedUrls( @RequestParam(defaultValue = "1") int count, diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java index fea6a326..b3068522 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java @@ -14,7 +14,9 @@ public enum FileUploadErrorCase implements ErrorCase { INVALID_IMAGE_FILE(400, 2003, "이미지 파일 형식이 올바르지 않습니다."), - FILE_SIZE_EXCEEDED(400, 2004, "파일 최대 용량을 초과했습니다."); + FILE_SIZE_EXCEEDED(400, 2004, "파일 최대 용량을 초과했습니다."), + + UPLOAD_RATE_LIMIT_EXCEEDED(429, 2005, "하루에 올릴 수 있는 이미지 개수를 초과했습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java index e23a8b32..43c8ba52 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java @@ -138,13 +138,29 @@ private void deleteProblemSolveImageData(String imageUrl, Long userId) { } private String createFileName(MultipartFile file) { - String originalFilename = file.getOriginalFilename(); - String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String extension = resolveExtension(file.getOriginalFilename()); String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); return "image/" + datePath + "/" + UUID.randomUUID() + extension; } + /** + * 확장자가 없는 파일명이 들어오면 예전에는 substring(-1) 로 StringIndexOutOfBoundsException 이 나 + * 그대로 500 이 됐다. 잘못된 업로드 요청은 400 으로 돌려준다. + */ + private String resolveExtension(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + + int separatorIndex = originalFilename.lastIndexOf("."); + if (separatorIndex < 0 || separatorIndex == originalFilename.length() - 1) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + + return originalFilename.substring(separatorIndex); + } + private String createFileNameForPresigned(String contentType) { String extension = switch (contentType) { case "image/png" -> ".png"; diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java b/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java index 12323f66..9fe2ba45 100644 --- a/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java @@ -1,24 +1,22 @@ package com.aisip.OnO.backend.util.quartz; -import org.quartz.spi.TriggerFiredBundle; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.scheduling.quartz.SpringBeanJobFactory; public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { - private AutowireCapableBeanFactory beanFactory; - + /** + * 부모에 ApplicationContext 를 넘겨야 잡을 {@code createBean} 으로 만든다. + * + *

예전에는 자기 필드만 채우고 부모를 부르지 않아, 부모가 리플렉션으로 인자 없는 생성자를 찾았다. + * 그래서 생성자 주입을 쓰는 잡(ChallengeNotificationJob, StudyRoomWeeklyReportJob)은 발화할 때마다 + * NoSuchMethodException 으로 실패하고 트리거가 ERROR 로 남았다. + * {@code createBean} 은 생성자 주입과 {@code @Autowired} 필드 주입을 모두 처리하므로 + * 따로 {@code autowireBean} 을 부르던 코드는 뺐다. + */ @Override public void setApplicationContext(ApplicationContext context) { - this.beanFactory = context.getAutowireCapableBeanFactory(); - } - - @Override - protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { - Object job = super.createJobInstance(bundle); - beanFactory.autowireBean(job); // ★ DI 핵심 - return job; + super.setApplicationContext(context); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java index 9d37926f..e7046c4d 100644 --- a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java @@ -1,6 +1,10 @@ package com.aisip.OnO.backend.util.quartz; import org.quartz.spi.JobFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.quartz.JobStoreType; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,20 +23,70 @@ public JobFactory jobFactory(ApplicationContext applicationContext) { return jobFactory; } + /** + * Quartz 스키마 초기화를 직접 맡는다. 자동 구성의 초기화 빈은 + * {@code @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class)} 라 + * 같은 타입인 이 빈이 있으면 물러난다. + * + *

자동 구성 그대로 두면 {@code initialize-schema: always} 인 dev/prod 에서 기동할 때마다 + * QRTZ_ 테이블을 지우고 다시 만들어, 사용자가 등록한 알림 트리거가 배포마다 사라졌다. + * 자세한 내용은 {@link QuartzSchemaInitializer} 주석에 적었다. + * + *

자동 구성과 같은 조건({@code job-store-type: jdbc})에서만 만든다. 메모리 잡스토어를 쓰는 + * 테스트 프로필에는 QRTZ_ 테이블 자체가 없어야 하므로 초기화 빈도 있으면 안 된다. + */ @Bean - public SchedulerFactoryBean schedulerFactoryBean(JobFactory jobFactory, DataSource dataSource) { + @ConditionalOnProperty(prefix = "spring.quartz", name = "job-store-type", havingValue = "jdbc") + public QuartzSchemaInitializer quartzSchemaInitializer(DataSource dataSource, + QuartzProperties quartzProperties) { + return new QuartzSchemaInitializer(dataSource, quartzProperties); + } + + /** + * 스케줄러를 직접 구성한다. Spring Boot 의 자동 구성은 이 빈이 있으면 물러난다. + * + *

이전에는 DataSource 와 autoStartup 을 코드에 박아 두어 {@code spring.quartz.*} 설정이 + * 통째로 무시됐다. 그 결과 테스트 프로필이 {@code job-store-type: memory} 를 선언해도 + * JDBC 잡스토어가 강제로 붙어, QRTZ_ 테이블이 없는 테스트 DB 에서 스케줄 등록이 실패했다. + * 스케줄러가 관여하는 경로는 목으로 대체할 수밖에 없었고 실제 등록 로직은 검증 대상 밖이었다. + * + *

이제 프로필 설정을 그대로 따른다. dev/prod/local 은 모두 + * {@code job-store-type: jdbc}, {@code auto-startup: true} 를 명시하고 있어 동작이 바뀌지 않는다. + */ + @Bean + public SchedulerFactoryBean schedulerFactoryBean(JobFactory jobFactory, + ObjectProvider dataSourceProvider, + QuartzProperties quartzProperties) { SchedulerFactoryBean factory = new SchedulerFactoryBean(); factory.setJobFactory(jobFactory); - factory.setDataSource(dataSource); + + // 메모리 잡스토어에 DataSource 를 물리면 Quartz 가 QRTZ_ 테이블을 찾는다. + if (quartzProperties.getJobStoreType() == JobStoreType.JDBC) { + factory.setDataSource(dataSourceProvider.getObject()); + } + + factory.setAutoStartup(quartzProperties.isAutoStartup()); + + // 아래 셋은 프로필 어디에도 설정돼 있지 않아, Spring Boot 기본값을 따르면 운영 동작이 바뀐다. + // 특히 overwriteExistingJobs 가 false 가 되면 cron 표현식을 고쳐도 재배포 시 반영되지 않는다. + // 테스트를 막던 것은 jobStoreType 과 autoStartup 뿐이므로 나머지는 기존 값을 유지한다. factory.setOverwriteExistingJobs(true); factory.setStartupDelay(5); - factory.setAutoStartup(true); factory.setWaitForJobsToCompleteOnShutdown(true); - // Quartz 스케줄러 시간대를 한국 시간으로 설정 - Properties quartzProperties = new Properties(); - quartzProperties.setProperty("org.quartz.scheduler.timeZone", "Asia/Seoul"); - factory.setQuartzProperties(quartzProperties); + Properties properties = new Properties(); + // 스케줄 표현식(cron)을 한국 시간으로 해석한다. + properties.setProperty("org.quartz.scheduler.timeZone", "Asia/Seoul"); + properties.putAll(quartzProperties.getProperties()); + + // jobStore.class 는 SchedulerFactoryBean 이 스스로 정하게 둔다. + // + // local/dev/prod 프로필이 org.quartz.jobStore.class = JobStoreTX 를 직접 지정하고 있는데, + // 이 값이 그대로 넘어가면 setDataSource 로 붙인 LocalDataSourceJobStore 를 덮어써 + // DataSource 를 모르는 순수 JobStoreTX 가 남고 기동이 "DataSource name not set." 로 실패한다. + // setDataSource 를 무조건 호출하던 시절에는 드러나지 않던 충돌이다. + properties.remove("org.quartz.jobStore.class"); + factory.setQuartzProperties(properties); return factory; } diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java new file mode 100644 index 00000000..ae00badf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java @@ -0,0 +1,155 @@ +package com.aisip.OnO.backend.util.quartz; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobDetail; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * ERROR 상태로 멈춘 Quartz 트리거를 주기적으로 WAITING 으로 되돌린다. + * + *

blue-green 배포 중에는 구 컨테이너가 수십 초 더 살아서 같은 QRTZ_ 테이블을 폴링한다. + * 구 이미지에 없는 잡 클래스의 트리거를 집으면 {@code ClassNotFoundException} 으로 트리거가 + * ERROR 가 되는데, Quartz 는 기동 시 복구({@code recoverJobs})에서도 트리거 획득에서도 + * ERROR 를 다시 보지 않는다. 그대로 두면 다음 재기동까지 그 잡이 한 번도 돌지 않는다. + * 기동 때 {@code rescheduleJob} 으로 트리거를 교체해도, 오류가 새 컨테이너 기동 뒤에 생기면 소용이 없다. + * + *

되돌릴 때는 이 인스턴스가 잡 클래스를 실제로 불러올 수 있는지 먼저 확인한다. 구 컨테이너에서 + * 이 점검이 돌면 자기가 모르는 잡을 되살렸다가 곧바로 다시 ERROR 로 만들기 때문이다. + * + *

blue 와 green 이 동시에 돌려도 안전하다. {@code resetTriggerFromErrorState} 는 + * {@code UPDATE ... WHERE TRIGGER_STATE = 'ERROR'} 한 문장이라, 먼저 바꾼 쪽만 반영되고 나머지는 + * 아무 일도 하지 않는다. + * + *

{@code @Scheduled} 대신 전용 스레드를 둔다. {@code @EnableScheduling} 을 켜면 Spring Boot 가 + * {@code ThreadPoolTaskScheduler} 빈을 만드는데, 지금은 {@code s3UploadExecutor} 때문에 기본 + * {@code TaskExecutor} 빈({@code applicationTaskExecutor})이 만들어지지 않아 그 스케줄러가 + * {@code @Async} 실행기로 잡힐 수 있다. 점검 하나 때문에 다른 비동기 경로의 실행기를 바꾸지 않는다. + */ +@Slf4j +@Component +public class QuartzErrorTriggerRecoverer { + + /** 새 컨테이너가 준비된 뒤 구 컨테이너가 내려갈 때까지 대략 1분이 걸린다. 그 뒤에 첫 점검을 한다. */ + static final Duration INITIAL_DELAY = Duration.ofMinutes(1); + + /** + * 가장 촘촘한 폴링 잡(복습 리마인더)과 같은 5분이다. 되돌린 크론 트리거는 오발화 정책에 따라 + * 곧바로 한 번 실행되므로, 점검 간격만큼 늦어질 뿐 회차가 사라지지는 않는다. + */ + static final Duration CHECK_INTERVAL = Duration.ofMinutes(5); + + private final Scheduler scheduler; + private ScheduledExecutorService executor; + + @Autowired + public QuartzErrorTriggerRecoverer(Scheduler scheduler) { + this.scheduler = scheduler; + } + + @EventListener(ApplicationReadyEvent.class) + public synchronized void start() { + if (executor != null) { + return; + } + executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "quartz-error-trigger-recoverer"); + thread.setDaemon(true); + return thread; + }); + executor.scheduleWithFixedDelay( + this::recoverSafely, + INITIAL_DELAY.toMillis(), + CHECK_INTERVAL.toMillis(), + TimeUnit.MILLISECONDS + ); + } + + @PreDestroy + public synchronized void stop() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + /** 예외가 밖으로 새면 ScheduledExecutorService 가 이후 실행을 조용히 멈추므로 여기서 모두 막는다. */ + private void recoverSafely() { + try { + recoverErrorTriggers(); + } catch (Exception e) { + log.error("[QuartzRecovery] ERROR 트리거 점검 실패", e); + } + } + + /** + * ERROR 상태인 트리거를 찾아 되돌린다. + * + * @return 되돌린 트리거 수 + */ + public int recoverErrorTriggers() throws SchedulerException { + int recovered = 0; + for (String group : scheduler.getTriggerGroupNames()) { + for (TriggerKey triggerKey : scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group))) { + if (recoverIfError(triggerKey)) { + recovered++; + } + } + } + return recovered; + } + + private boolean recoverIfError(TriggerKey triggerKey) { + try { + if (scheduler.getTriggerState(triggerKey) != Trigger.TriggerState.ERROR) { + return false; + } + + Trigger trigger = scheduler.getTrigger(triggerKey); + if (trigger == null) { + return false; + } + + if (!canLoadJob(trigger)) { + return false; + } + + scheduler.resetTriggerFromErrorState(triggerKey); + log.warn("[QuartzRecovery] ERROR 상태 트리거를 되돌렸습니다 - trigger: {}, job: {}", + triggerKey, trigger.getJobKey()); + return true; + } catch (SchedulerException e) { + log.error("[QuartzRecovery] 트리거 복구 실패 - trigger: {}", triggerKey, e); + return false; + } + } + + private boolean canLoadJob(Trigger trigger) { + try { + JobDetail jobDetail = scheduler.getJobDetail(trigger.getJobKey()); + if (jobDetail == null) { + log.warn("[QuartzRecovery] ERROR 트리거의 잡이 없어 되돌리지 않습니다 - trigger: {}, job: {}", + trigger.getKey(), trigger.getJobKey()); + return false; + } + return true; + } catch (SchedulerException e) { + log.warn("[QuartzRecovery] 이 인스턴스가 잡을 불러오지 못해 ERROR 트리거를 그대로 둡니다 - trigger: {}, job: {}, 원인: {}", + trigger.getKey(), trigger.getJobKey(), e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java new file mode 100644 index 00000000..164b409b --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.util.quartz; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.quartz.QuartzDataSourceScriptDatabaseInitializer; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * QRTZ_ 테이블이 이미 있으면 Quartz 스키마 초기화 스크립트를 실행하지 않는다. + * + *

Spring Boot 의 {@code spring.quartz.jdbc.initialize-schema: always} 는 기동할 때마다 + * Quartz 가 제공하는 {@code tables_mysql_innodb.sql} 을 그대로 실행한다. 그 스크립트는 + * {@code DROP TABLE IF EXISTS QRTZ_...} 11 줄로 시작한다. 그래서 배포할 때마다 QRTZ_ 테이블이 + * 통째로 다시 만들어졌고, 사용자가 등록한 복습노트 알림 트리거({@code trigger-{practiceId}})와 + * 챌린지 알림 트리거가 전부 사라졌다. 기동할 때 {@code @PostConstruct} 로 다시 등록하는 + * 고정 트리거 세 개만 살아남았다. + * + *

운영에 쓰이는 {@code application-prod.yml} 은 레포에 없고 CI secret 으로 주입된다. + * 설정 파일만 고쳐서는 운영에 반영되지 않으므로, 설정이 {@code always} 인 채로 배포되더라도 + * 테이블이 지워지지 않게 코드에서 막는다. + * + *

자동 구성의 초기화 빈은 {@code @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class)} + * 로 등록된다({@code QuartzAutoConfiguration.JdbcStoreTypeConfiguration}). 같은 타입인 이 빈을 + * 직접 정의하면 자동 구성이 물러난다. + * + *

테이블이 없을 때는 상위 구현에 그대로 위임한다. QRTZ_ 스키마는 Flyway + * {@code V45__create_quartz_tables.sql} 이 만들고, Flyway 는 이 초기화 빈보다 먼저 돈다 + * ({@code DataSourceScriptDatabaseInitializerDetector} 의 순서가 Flyway 감지기보다 뒤라 + * 스크립트 초기화 빈이 Flyway 에 의존하도록 엮인다). 즉 정상 경로에서는 여기서 스크립트가 + * 실행될 일이 없고, Flyway 를 끈 환경을 위한 대비책으로만 남는다. + */ +public class QuartzSchemaInitializer extends QuartzDataSourceScriptDatabaseInitializer { + + private static final Logger log = LoggerFactory.getLogger(QuartzSchemaInitializer.class); + + private static final String TABLE_PREFIX_PROPERTY = "org.quartz.jobStore.tablePrefix"; + private static final String DEFAULT_TABLE_PREFIX = "QRTZ_"; + + private final DataSource dataSource; + private final String triggersTableName; + + public QuartzSchemaInitializer(DataSource dataSource, QuartzProperties properties) { + super(dataSource, properties); + this.dataSource = dataSource; + this.triggersTableName = resolveTablePrefix(properties) + "TRIGGERS"; + } + + @Override + public boolean initializeDatabase() { + if (quartzSchemaExists()) { + log.info("Quartz 스키마가 이미 있어 초기화 스크립트를 건너뛴다. (기준 테이블: {})", triggersTableName); + return false; + } + + log.info("Quartz 스키마가 없어 초기화 스크립트를 실행한다. (기준 테이블: {})", triggersTableName); + return super.initializeDatabase(); + } + + /** + * 트리거 테이블이 이미 있는지 본다. + * + *

확인에 실패하면 "있다"로 답한다. 초기화 스크립트는 되돌릴 수 없는 DROP 으로 시작하므로, + * 상태를 모르는 채로 실행하는 쪽이 건너뛰는 쪽보다 훨씬 위험하다. 테이블이 정말 없었다면 + * 잠시 뒤 스케줄러가 기동하면서 명확한 오류로 드러난다. + */ + private boolean quartzSchemaExists() { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metaData = connection.getMetaData(); + try (ResultSet tables = metaData.getTables( + connection.getCatalog(), connection.getSchema(), null, new String[]{"TABLE"})) { + while (tables.next()) { + if (triggersTableName.equalsIgnoreCase(tables.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } catch (SQLException e) { + log.warn("Quartz 스키마 존재 여부를 확인하지 못했다. 초기화 스크립트를 실행하지 않는다.", e); + return true; + } + } + + private static String resolveTablePrefix(QuartzProperties properties) { + String prefix = properties.getProperties().get(TABLE_PREFIX_PROPERTY); + return StringUtils.hasText(prefix) ? prefix : DEFAULT_TABLE_PREFIX; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java b/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java index 60e01826..893281f5 100644 --- a/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java @@ -20,6 +20,7 @@ public class RedisTokenService { private static final String REFRESH_TOKEN_PREFIX = "RT:"; // RefreshToken prefix private static final String BLACKLIST_PREFIX = "BL:"; // BlackList prefix + private static final String USER_BLACKLIST_PREFIX = "UBL:"; // 사용자 단위 BlackList prefix (탈퇴) /** * RefreshToken을 Redis에 저장 @@ -75,4 +76,30 @@ public boolean isBlacklisted(String accessToken) { String result = redisSingleDataService.getSingleData(key); return !result.isEmpty(); } + + /** + * 사용자 단위로 이미 발급된 액세스 토큰을 전부 막는다 (탈퇴 처리). + * + *

로그아웃 블랙리스트는 토큰 문자열이 키라서, 탈퇴 시점에 서버가 손에 쥔 그 한 장만 막을 수 있다. + * 같은 계정이 다른 기기에서 받아 간 토큰은 그대로 남는다. 그래서 사용자 단위 키를 따로 둔다. + * + * @param userId 사용자 ID + * @param expiration 표식을 유지할 기간 (초). 그 계정 앞으로 유효한 자격증명이 남아 있을 수 있는 기간을 넣는다. + */ + public void blacklistUser(Long userId, long expiration) { + String key = USER_BLACKLIST_PREFIX + userId; + redisSingleDataService.setSingleData(key, "withdrawn", Duration.ofSeconds(expiration)); + log.info("Added userId: {} to user blacklist for {}s", userId, expiration); + } + + /** + * 사용자 단위 블랙리스트에 올라 있는지 확인 + * @param userId 사용자 ID + * @return 블랙리스트에 있으면 true + */ + public boolean isUserBlacklisted(Long userId) { + String key = USER_BLACKLIST_PREFIX + userId; + String result = redisSingleDataService.getSingleData(key); + return !result.isEmpty(); + } } diff --git a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java index 9028f4d1..733ea3aa 100644 --- a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java +++ b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java @@ -19,6 +19,7 @@ public class DiscordWebhookNotificationService { private final DiscordWebhookProducer discordWebhookProducer; private final Map lastSentAtByDedupKey = new ConcurrentHashMap<>(); private static final Duration ERROR_NOTIFICATION_DEDUP_WINDOW = Duration.ofMinutes(5); + private static final int DEDUP_KEY_CAPACITY = 1000; /** * 에러 발생 시 Discord로 알림 전송 @@ -89,12 +90,30 @@ public void sendCustomEmbed(String title, String description, List entry.getValue().isBefore(threshold)); + } + private void publishToQueue(DiscordWebhookPayload payload) { publishToQueue(payload, null); } diff --git a/src/main/resources/db/migration/V21__add_problem_review_reminder.sql b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql new file mode 100644 index 00000000..30ff2e23 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql @@ -0,0 +1,31 @@ +CREATE TABLE problem_review_reminder ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + problem_id BIGINT NOT NULL, + problem_memo_snapshot VARCHAR(255) NULL, + problem_reference_snapshot VARCHAR(255) NULL, + sequence INT NOT NULL, + interval_days INT NOT NULL, + scheduled_at DATETIME(6) NOT NULL, + status VARCHAR(30) NOT NULL, + sent_at DATETIME(6) NULL, + last_error_message VARCHAR(500) NULL, + retry_count INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id) +); + +CREATE INDEX idx_problem_review_reminder_due + ON problem_review_reminder (status, scheduled_at); + +CREATE INDEX idx_problem_review_reminder_user_due + ON problem_review_reminder (user_id, status, scheduled_at); + +CREATE INDEX idx_problem_review_reminder_problem + ON problem_review_reminder (problem_id, status); + +ALTER TABLE problem_review_reminder + ADD CONSTRAINT uq_problem_review_reminder_seq + UNIQUE (problem_id, sequence); diff --git a/src/main/resources/db/migration/V22__add_missing_indexes.sql b/src/main/resources/db/migration/V22__add_missing_indexes.sql new file mode 100644 index 00000000..41d3e561 --- /dev/null +++ b/src/main/resources/db/migration/V22__add_missing_indexes.sql @@ -0,0 +1,9 @@ +-- image_data: problem_id + image_type + created_at 복합 인덱스 +-- validateSolveImageNotRegisteredToday 쿼리 최적화 +CREATE INDEX idx_image_data_problem_type_created + ON image_data (problem_id, image_type, created_at); + +-- problem_review_reminder: NOT EXISTS 서브쿼리 최적화 +-- findDueReminders의 (user_id, status, sent_at, deleted_at) 필터 +CREATE INDEX idx_reminder_sent + ON problem_review_reminder (user_id, status, sent_at, deleted_at); diff --git a/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql b/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql new file mode 100644 index 00000000..e727d2e2 --- /dev/null +++ b/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql @@ -0,0 +1,57 @@ +-- soft delete 와 유니크 인덱스가 어긋나던 것을 고친다. +-- +-- tag 와 problem_tag_mapping 은 @SQLDelete 로 deleted_at 만 채우고 행을 남기는데, +-- 유니크 인덱스는 deleted_at 을 보지 않아서 삭제된 행이 계속 자리를 차지하고 있었다. +-- 조회는 @SQLRestriction("deleted_at IS NULL") 때문에 그 행을 못 찾으니 INSERT 로 넘어가고, +-- DB 제약에 걸려 500 이 났다. 그래서 한 번 지운 태그는 같은 이름으로 다시 만들 수 없었다. +-- (Sentry JAVA-SPRING-BOOT-4Z: Duplicate entry '434-발상' for key 'tag.idx_tag_user_normalized') +-- +-- 인덱스에 alive_key 를 넣어서 "살아있는 행은 하나뿐, 삭제된 행은 여러 개 공존"을 만든다. +-- 살아있는 행: alive_key = 1970-01-01 (모두 같은 값이라 중복이 막힌다) +-- 삭제된 행: alive_key = deleted_at (삭제 시각이 서로 달라 여러 건이 공존한다) +-- +-- id(AUTO_INCREMENT)를 참조하면 MySQL 이 거부하기 때문에(ERROR 3109) deleted_at 을 쓴다. +-- VIRTUAL 이라 테이블을 다시 쓰지 않고 메타데이터만 바뀐다. + +-- --------------------------------------------------------------------------- +-- tag +-- --------------------------------------------------------------------------- +ALTER TABLE tag + ADD COLUMN alive_key DATETIME(6) + AS (IFNULL(deleted_at, '1970-01-01 00:00:00.000000')) VIRTUAL; + +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'tag' + AND index_name = 'idx_tag_user_normalized'); +SET @sql := IF(@idx > 0, + 'ALTER TABLE tag DROP INDEX idx_tag_user_normalized', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE UNIQUE INDEX idx_tag_user_normalized + ON tag (user_id, normalized_name, alive_key); + +-- --------------------------------------------------------------------------- +-- problem_tag_mapping +-- 같은 구조라 태그 생성이 풀리면 다음은 여기서 같은 500 이 난다. +-- --------------------------------------------------------------------------- +ALTER TABLE problem_tag_mapping + ADD COLUMN alive_key DATETIME(6) + AS (IFNULL(deleted_at, '1970-01-01 00:00:00.000000')) VIRTUAL; + +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'problem_tag_mapping' + AND index_name = 'uk_problem_tag_mapping_problem_tag'); +SET @sql := IF(@idx > 0, + 'ALTER TABLE problem_tag_mapping DROP INDEX uk_problem_tag_mapping_problem_tag', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE UNIQUE INDEX uk_problem_tag_mapping_problem_tag + ON problem_tag_mapping (problem_id, tag_id, alive_key); diff --git a/src/main/resources/db/migration/V24__widen_problem_memo.sql b/src/main/resources/db/migration/V24__widen_problem_memo.sql new file mode 100644 index 00000000..3e573d49 --- /dev/null +++ b/src/main/resources/db/migration/V24__widen_problem_memo.sql @@ -0,0 +1,9 @@ +-- 메모가 255자를 넘으면 문제 등록 자체가 500 으로 실패했다 (Sentry JAVA-SPRING-BOOT-5B / 5A). +-- memo 는 ddl-auto 시절 기본값 varchar(255) 로 만들어졌고, 사용자 자유 입력이라 쉽게 넘긴다. +-- +-- utf8mb4 기준 varchar(255) 는 이미 1020 바이트라 2바이트 길이 접두사를 쓰고 있어, +-- varchar(1000)(4000 바이트) 로 늘려도 접두사 크기가 그대로다 → INPLACE 로 테이블 재작성 없이 처리된다. +-- TEXT 로 바꾸면 ALGORITHM=COPY 가 되어 problem 테이블 전체가 잠기므로 선택하지 않았다. +ALTER TABLE problem + MODIFY COLUMN memo VARCHAR(1000) NULL, + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/main/resources/db/migration/V25__widen_reminder_memo_snapshot.sql b/src/main/resources/db/migration/V25__widen_reminder_memo_snapshot.sql new file mode 100644 index 00000000..2b2c8f83 --- /dev/null +++ b/src/main/resources/db/migration/V25__widen_reminder_memo_snapshot.sql @@ -0,0 +1,74 @@ +-- problem_review_reminder.problem_memo_snapshot 을 varchar(255) -> varchar(1000) 으로 넓힌다. +-- +-- V21 이 이 컬럼을 VARCHAR(255) 로 만들었다. problem.memo 를 그대로 복사해 담는 스냅샷이라 +-- V23 만 적용하면 리마인더 생성 시점에 같은 truncation 이 그대로 재발한다. +-- 반드시 V23 과 함께 나가야 한다. +-- +-- 같은 테이블의 problem_reference_snapshot(255) 은 원본 problem.reference 가 255 라 그대로 둔다. +-- +-- [프로덕션 적용 순서 - 실측 반영] +-- 프로덕션 조회 결과 problem_review_reminder 테이블 자체가 아직 없다. V21 이 프로덕션에 +-- 적용되지 않았다는 뜻이다. Flyway 는 버전 순서대로 적용하므로 다음 배포에서 +-- V21 이 테이블을 만든 뒤 이 파일이 컬럼을 넓힌다. 순서상 문제는 없다. +-- +-- V21 을 직접 고쳐 처음부터 VARCHAR(1000) 으로 만드는 편이 깔끔해 보이지만 그렇게 하지 않는다. +-- V21 이 dev 에는 이미 적용됐을 수 있고, validate-on-migrate: true 이므로 적용된 마이그레이션의 +-- 내용을 바꾸면 체크섬 불일치로 dev 기동이 실패한다. +-- +-- [락 / 알고리즘] +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. 절이 없으면 MySQL 이 알아서 고르는데, +-- INPLACE 가 불가능한 조건이면 에러 없이 조용히 COPY 로 내려가 테이블을 통째로 재작성하고 +-- 그동안 쓰기가 막힌다. 명시하면 불가능할 때 ER_ALTER_OPERATION_NOT_SUPPORTED(1845) 로 +-- 즉시 실패하므로, 알 수 없는 길이의 장애가 1초짜리 실패로 바뀐다. +-- +-- lock_wait_timeout 은 MDL 대기 pile-up 방지용이다. 메타데이터만 바꾸는 ALTER 도 시작·종료에 +-- 배타적 MDL 을 잠깐 잡는데, 장시간 트랜잭션이 테이블을 붙들고 있으면 DDL 이 대기하고 +-- 그 뒤의 모든 쿼리가 DDL 뒤에 줄을 선다. 10초로 끊으면 실패는 하되 서비스는 멀쩡하다. +-- +-- [적용 전 확인 - 읽기 전용] +-- MODIFY COLUMN 은 컬럼 정의를 통째로 교체한다. 명시하지 않은 CHARACTER SET/COLLATE 는 +-- 유지되지 않고 테이블 기본값으로 리셋된다. 컬럼 collation 이 테이블 기본과 다르면 +-- collation 변경 = 인덱스 재구축 = COPY 가 되므로, 아래로 먼저 확인하고 +-- 다를 경우 현재 값을 ALTER 에 그대로 재기술해야 한다. +-- SELECT column_name, character_maximum_length, character_octet_length, +-- character_set_name, collation_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'problem_review_reminder'; +-- SHOW CREATE TABLE problem_review_reminder; + +-- +-- [실패 시 복구] +-- MySQL 은 DDL 트랜잭션이 없다. 실패하면 flyway_schema_history 에 success=0 으로 남고, +-- validate-on-migrate: true 라 이후 모든 기동이 "Detected failed migration" 으로 죽는다. +-- 배포 파이프라인이 막히므로 아래로 이력을 정리한 뒤 재배포한다. +-- SELECT installed_rank, version, description, success +-- FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5; +-- DELETE FROM flyway_schema_history WHERE version = '25' AND success = 0; +-- 이 ALTER 는 목표 정의로 수렴하는 문장이라 이미 적용된 상태에서 재실행해도 안전하다. +-- +-- [롤백] +-- 되돌리지 않는 것이 원칙이다. 컬럼 확장은 구버전 앱과도 호환되므로 앱만 되돌리면 된다. +-- 굳이 축소해야 한다면 순서와 비용을 알고 해야 한다: +-- 1. 앱을 먼저 롤백한다. 앱이 긴 값을 통과시키는 상태에서 컬럼만 줄이면 즉시 500 이 재발한다. +-- 2. 초과 데이터가 0 건인지 확인한다: +-- SELECT COUNT(*) FROM problem_review_reminder WHERE CHAR_LENGTH(problem_memo_snapshot) > 255; +-- 3. VARCHAR 축소는 INPLACE 가 불가능해 테이블 전체 재작성 + 쓰기 차단이다. 확장보다 위험하다. +-- 4. flyway_schema_history 에서 해당 버전 행을 지운다. 안 지우면 스키마와 이력이 영구히 어긋난다. + +-- +-- [collation 명시 이유 - 프로덕션 실측 반영] +-- 프로덕션 조회 결과 이 컬럼은 utf8mb4 / utf8mb4_unicode_ci 이고 octet_length 는 1020 이다. +-- MODIFY COLUMN 은 명시하지 않은 CHARACTER SET/COLLATE 를 테이블 기본값으로 리셋하므로, +-- 테이블 기본값이 컬럼과 다를 경우 collation 이 바뀌면서 인덱스 재구축(COPY)이 일어난다. +-- 현재 값을 그대로 재기술해 테이블 기본값이 무엇이든 컬럼 정의가 바뀌지 않도록 한다. +-- +-- 길이 프리픽스: 1020 octet -> 목표 크기도 255 octet 초과라 둘 다 2바이트 구간이다. +-- 프리픽스 크기가 바뀌지 않으므로 메타데이터 변경만으로 끝난다. + + +SET SESSION lock_wait_timeout = 10; + +ALTER TABLE problem_review_reminder + MODIFY COLUMN problem_memo_snapshot VARCHAR(1000) + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/src/main/resources/db/migration/V26__widen_refresh_token.sql b/src/main/resources/db/migration/V26__widen_refresh_token.sql new file mode 100644 index 00000000..34d74b67 --- /dev/null +++ b/src/main/resources/db/migration/V26__widen_refresh_token.sql @@ -0,0 +1,74 @@ +-- refresh_token.refresh_token 을 varchar(255) -> varchar(512) 로 넓힌다. +-- +-- 원본 JWT 를 그대로 저장하는데 현재 클레임 구성으로 실측 최대 244자다. 아직 터지지는 +-- 않았지만 클레임이 하나만 늘어도 255를 넘긴다. 저장이 잘리면 이후 갱신 요청이 세션을 +-- 찾지 못해 REFRESH_TOKEN_NOT_FOUND(1002) 가 된다. +-- +-- 셋 중 유일하게 인덱스가 걸린 컬럼이고, collation 이 바뀌면 인증 경로의 동등 비교에 +-- 영향이 갈 수 있는 유일한 컬럼이다. 지금 터지는 버그가 아니라 예방적 확장이므로 +-- V23/V24 와 같은 배포에 묶지 말고, 트래픽이 낮은 시간대에 단독으로 적용하는 것을 권한다. +-- +-- 프로덕션 flyway_schema_history 확인 결과 V17 은 적용되어 있다(2026-06-30). 즉 +-- 프리픽스 인덱스 idx_refresh_token_token (refresh_token(255)) 가 프로덕션에 실재한다. +-- 이 인덱스는 그대로 둔다. +-- 프리픽스 길이는 문자 수 기준이라 utf8mb4 에서 1020바이트이고 InnoDB 한계(3072) 안이다. +-- 컬럼이 넓어져도 인덱스 정의는 유효하며, 동등 비교는 프리픽스로 후보를 좁힌 뒤 +-- 행에서 전체 값을 재확인하는 방식이라 정확성도 유지된다. +-- +-- [락 / 알고리즘] +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. 절이 없으면 MySQL 이 알아서 고르는데, +-- INPLACE 가 불가능한 조건이면 에러 없이 조용히 COPY 로 내려가 테이블을 통째로 재작성하고 +-- 그동안 쓰기가 막힌다. 명시하면 불가능할 때 ER_ALTER_OPERATION_NOT_SUPPORTED(1845) 로 +-- 즉시 실패하므로, 알 수 없는 길이의 장애가 1초짜리 실패로 바뀐다. +-- +-- lock_wait_timeout 은 MDL 대기 pile-up 방지용이다. 메타데이터만 바꾸는 ALTER 도 시작·종료에 +-- 배타적 MDL 을 잠깐 잡는데, 장시간 트랜잭션이 테이블을 붙들고 있으면 DDL 이 대기하고 +-- 그 뒤의 모든 쿼리가 DDL 뒤에 줄을 선다. 10초로 끊으면 실패는 하되 서비스는 멀쩡하다. +-- +-- [적용 전 확인 - 읽기 전용] +-- MODIFY COLUMN 은 컬럼 정의를 통째로 교체한다. 명시하지 않은 CHARACTER SET/COLLATE 는 +-- 유지되지 않고 테이블 기본값으로 리셋된다. 컬럼 collation 이 테이블 기본과 다르면 +-- collation 변경 = 인덱스 재구축 = COPY 가 되므로, 아래로 먼저 확인하고 +-- 다를 경우 현재 값을 ALTER 에 그대로 재기술해야 한다. +-- SELECT column_name, character_maximum_length, character_octet_length, +-- character_set_name, collation_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'refresh_token'; +-- SHOW CREATE TABLE refresh_token; + +-- +-- [실패 시 복구] +-- MySQL 은 DDL 트랜잭션이 없다. 실패하면 flyway_schema_history 에 success=0 으로 남고, +-- validate-on-migrate: true 라 이후 모든 기동이 "Detected failed migration" 으로 죽는다. +-- 배포 파이프라인이 막히므로 아래로 이력을 정리한 뒤 재배포한다. +-- SELECT installed_rank, version, description, success +-- FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5; +-- DELETE FROM flyway_schema_history WHERE version = '25' AND success = 0; +-- 이 ALTER 는 목표 정의로 수렴하는 문장이라 이미 적용된 상태에서 재실행해도 안전하다. +-- +-- [롤백] +-- 되돌리지 않는 것이 원칙이다. 컬럼 확장은 구버전 앱과도 호환되므로 앱만 되돌리면 된다. +-- 굳이 축소해야 한다면 순서와 비용을 알고 해야 한다: +-- 1. 앱을 먼저 롤백한다. 앱이 긴 값을 통과시키는 상태에서 컬럼만 줄이면 즉시 500 이 재발한다. +-- 2. 초과 데이터가 0 건인지 확인한다: +-- SELECT COUNT(*) FROM refresh_token WHERE CHAR_LENGTH(refresh_token) > 255; +-- 3. VARCHAR 축소는 INPLACE 가 불가능해 테이블 전체 재작성 + 쓰기 차단이다. 확장보다 위험하다. +-- 4. flyway_schema_history 에서 해당 버전 행을 지운다. 안 지우면 스키마와 이력이 영구히 어긋난다. + +-- +-- [collation 명시 이유 - 프로덕션 실측 반영] +-- 프로덕션 조회 결과 이 컬럼은 utf8mb4 / utf8mb4_unicode_ci 이고 octet_length 는 1020 이다. +-- MODIFY COLUMN 은 명시하지 않은 CHARACTER SET/COLLATE 를 테이블 기본값으로 리셋하므로, +-- 테이블 기본값이 컬럼과 다를 경우 collation 이 바뀌면서 인덱스 재구축(COPY)이 일어난다. +-- 현재 값을 그대로 재기술해 테이블 기본값이 무엇이든 컬럼 정의가 바뀌지 않도록 한다. +-- +-- 길이 프리픽스: 1020 octet -> 목표 크기도 255 octet 초과라 둘 다 2바이트 구간이다. +-- 프리픽스 크기가 바뀌지 않으므로 메타데이터 변경만으로 끝난다. + + +SET SESSION lock_wait_timeout = 10; + +ALTER TABLE refresh_token + MODIFY COLUMN refresh_token VARCHAR(512) + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/src/main/resources/db/migration/V27__add_service_notice.sql b/src/main/resources/db/migration/V27__add_service_notice.sql new file mode 100644 index 00000000..c0eb71af --- /dev/null +++ b/src/main/resources/db/migration/V27__add_service_notice.sql @@ -0,0 +1,19 @@ +CREATE TABLE service_notice ( + id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + content VARCHAR(500) NOT NULL, + type VARCHAR(20) NOT NULL, + starts_at DATETIME(6) NOT NULL, + expires_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +-- 활성 공지 조회는 deleted_at IS NULL AND starts_at <= now < expires_at 한 가지뿐이다. +-- 만료 시각이 먼저 걸러 주는 조건이라 expires_at 을 선행 컬럼으로 둔다. +CREATE INDEX idx_service_notice_active + ON service_notice (expires_at, starts_at); diff --git a/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql b/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql new file mode 100644 index 00000000..b3fed806 --- /dev/null +++ b/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql @@ -0,0 +1,5 @@ +-- 복습 기록마다 "이번 복습 어땠는지"를 이모지로 남긴다. +-- 이모지 키는 CustomEmojiValidator 화이트리스트 값이라 learning_calendar_mood 와 같은 VARCHAR(80) 을 쓴다. +-- 기존 복습 기록은 이모지 없이 그대로 두므로 nullable 이고 백필하지 않는다. +ALTER TABLE problem_solve + ADD COLUMN mood_emoji_key VARCHAR(80) NULL; diff --git a/src/main/resources/db/migration/V29__create_mission_system.sql b/src/main/resources/db/migration/V29__create_mission_system.sql new file mode 100644 index 00000000..3d6815aa --- /dev/null +++ b/src/main/resources/db/migration/V29__create_mission_system.sql @@ -0,0 +1,52 @@ +-- 미션 시스템 1차: 일일/주간 미션 정의와 사용자별 진행도. 이 파일은 DDL 만 담는다. +-- 시드는 V30 에 따로 둔다. MySQL DDL 은 트랜잭션이 아니라서, 테이블은 만들어지고 뒤이은 INSERT 에서 끊기면 +-- Flyway 는 실패로 기록하는데 테이블은 남는다. 재기동하면 "table already exists" 로 또 실패해 +-- flyway repair 없이는 앱이 뜨지 않는다. blue-green 배포 구간이면 그대로 장애다. +-- +-- 같은 이유로 두 테이블 모두 IF NOT EXISTS 로 만들고, 조회용 인덱스도 별도 CREATE INDEX 가 아니라 +-- 테이블 정의 안에 둔다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 그 문장이 +-- 재실행 지점이 되기 때문이다. 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. + +CREATE TABLE IF NOT EXISTS mission_definition ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL, + title VARCHAR(60) NOT NULL, + description VARCHAR(200) NOT NULL, + icon_key VARCHAR(40) NOT NULL, + category VARCHAR(20) NOT NULL, + metric VARCHAR(40) NOT NULL, + target INT NOT NULL, + reward_type VARCHAR(20) NOT NULL, + reward_value INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mission_definition_code (code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- target_snapshot: 나중에 미션 목표를 바꿔도 진행 중이던 사용자는 옛 목표로 끝나야 한다. +-- 진행도 행을 만들 때의 target 을 박아두고 완료 판정은 이 값으로 한다. +-- +-- uk_mission_progress 는 성능용이 아니라 정합성용이다. 진행도 증가를 +-- INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 처리하는데, 그 upsert 가 성립하는 근거가 이 유니크 키다. +-- +-- idx_mission_progress_lookup 은 "이 사용자의 이번 기간 진행도 전부" 조회용이다. +CREATE TABLE IF NOT EXISTS mission_progress ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + mission_id BIGINT NOT NULL, + period_key VARCHAR(20) NOT NULL, + current_value INT NOT NULL DEFAULT 0, + target_snapshot INT NOT NULL, + completed_at DATETIME(6) NULL, + claimed_at DATETIME(6) NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mission_progress (user_id, mission_id, period_key), + KEY idx_mission_progress_lookup (user_id, period_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V30__seed_mission_definitions.sql b/src/main/resources/db/migration/V30__seed_mission_definitions.sql new file mode 100644 index 00000000..8ffee7f6 --- /dev/null +++ b/src/main/resources/db/migration/V30__seed_mission_definitions.sql @@ -0,0 +1,25 @@ +-- 미션 정의 시드 10종 (일일 6, 주간 4). 관리자 화면은 2차라 정의는 마이그레이션으로만 들어온다. +-- +-- ON DUPLICATE KEY UPDATE code = code 는 "이미 있으면 아무것도 하지 않는다"는 뜻이다. +-- uk_mission_definition_code 가 중복을 잡아 주므로, 이 문장은 몇 번을 다시 돌려도 결과가 같다. +-- 운영 중에 목표나 보상을 손으로 고쳤을 수 있어 덮어쓰지 않는다. 값을 바꾸려면 새 마이그레이션을 쓴다. + +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('DAILY_ATTEND', '출석', '오늘 앱 켜기', 'attendance', 'DAILY', 'LOGIN_DAY', 1, 'XP', 10, 1, 1, NOW(6), NOW(6)), + ('DAILY_NOTE_WRITE', '오늘의 오답', '오답노트 1개 등록', 'note_write', 'DAILY', 'PROBLEM_CREATED', 1, 'XP', 10, 2, 1, NOW(6), NOW(6)), + ('DAILY_REVIEW_3', '세 문제만', '오답 3문제 복습', 'review', 'DAILY', 'SOLVE_RECORDED', 3, 'XP', 15, 3, 1, NOW(6), NOW(6)), + ('DAILY_CORRECT_3', '정확하게', '복습해서 3문제 맞히기', 'accuracy', 'DAILY', 'SOLVE_CORRECT', 3, 'XP', 20, 4, 1, NOW(6), NOW(6)), + ('DAILY_PRACTICE_SET', '세트 완주', '복습 세트 하나 끝내기', 'practice_set', 'DAILY', 'PRACTICE_NOTE_COMPLETED', 1, 'XP', 15, 5, 1, NOW(6), NOW(6)), + ('DAILY_MOOD', '오늘 기분', '학습 달력에 기분 남기기', 'mood', 'DAILY', 'MOOD_LOGGED', 1, 'XP', 5, 6, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE code = code; + +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('WEEKLY_ATTEND_5', '꾸준함', '이번 주 5일 출석', 'attendance', 'WEEKLY', 'LOGIN_DAY', 5, 'XP', 100, 1, 1, NOW(6), NOW(6)), + ('WEEKLY_NOTE_10', '열 권의 노트', '오답노트 10개 등록', 'note_write', 'WEEKLY', 'PROBLEM_CREATED', 10, 'XP', 80, 2, 1, NOW(6), NOW(6)), + ('WEEKLY_REVIEW_30', '서른 번의 복습', '복습 30회', 'review', 'WEEKLY', 'SOLVE_RECORDED', 30, 'XP', 100, 3, 1, NOW(6), NOW(6)), + ('WEEKLY_SET_3', '세 번의 완주', '복습 세트 3개 끝내기', 'practice_set', 'WEEKLY', 'PRACTICE_NOTE_COMPLETED', 3, 'XP', 80, 4, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE code = code; diff --git a/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql new file mode 100644 index 00000000..c440aa24 --- /dev/null +++ b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql @@ -0,0 +1,30 @@ +-- 미션 보상 획득 기록 조회(GET /api/missions/history)용 인덱스. +-- +-- 조회 형태는 "내 것 중 받은 것만, 받은 시각 역순" 한 가지다. +-- WHERE user_id = ? AND claimed_at IS NOT NULL ORDER BY claimed_at DESC +-- 기존 인덱스는 (user_id, period_key) 뿐이라 이 조회를 타지 못하고 사용자의 전체 진행도를 훑는다. +-- +-- 정렬 키를 선행 컬럼 뒤에 두면 MySQL 이 인덱스를 역방향으로 읽어 정렬 자체를 생략한다. +-- claimed_at 이 같은 행끼리의 순서는 InnoDB 가 보조 인덱스 끝에 붙이는 기본키(id)가 갈라 주므로 +-- 커서 페이지네이션의 동점 처리까지 이 인덱스 하나로 해결된다. +-- +-- V29 가 CREATE TABLE IF NOT EXISTS 로 재실행에 대비한 것과 같은 이유로 여기도 대비한다. +-- DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 커넥션이 끊기면, 재기동 때 이 파일이 다시 돌면서 +-- Duplicate key name 으로 앱이 뜨지 않는다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 +-- information_schema 를 보고 없을 때만 실행한다. + +SET @index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'mission_progress' + AND index_name = 'idx_mission_progress_claimed' +); + +SET @ddl := IF(@index_exists = 0, + 'CREATE INDEX idx_mission_progress_claimed ON mission_progress (user_id, claimed_at)', + 'SELECT 1'); + +PREPARE create_claimed_index FROM @ddl; +EXECUTE create_claimed_index; +DEALLOCATE PREPARE create_claimed_index; diff --git a/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql new file mode 100644 index 00000000..263c0299 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql @@ -0,0 +1,32 @@ +-- 보상 스냅샷. 받은 시점의 보상 종류와 값을 진행도 행에 박아 둔다. +-- +-- 기록 조회가 "현재" 미션 정의의 보상값을 읽으면, 운영 중에 보상을 바꿨을 때 +-- 예전에 받은 기록까지 새 값으로 보인다. 80 XP 를 받은 사람의 기록이 어느 날 50 XP 로 바뀐다. +-- target_snapshot 을 둔 이유와 정확히 같은 문제다. +-- +-- 지금 넣는 게 싸다. 보상 받기 기능이 아직 배포 전이라 claimed_at 이 채워진 행이 운영에 하나도 없다. +-- 나중에 넣으면 이미 받은 행들을 소급해서 채워야 하는데, 그때의 보상값은 알 방법이 없다. +-- +-- 옛 행을 위해 nullable 로 둔다. 스냅샷이 비어 있으면 조회가 현재 정의로 폴백한다. +-- +-- V29·V31 과 같은 이유로 재실행에 대비한다. ALTER 가 커밋된 뒤 이력이 남기 전에 끊기면 +-- 재기동 때 Duplicate column name 으로 앱이 뜨지 않는다. MySQL 에는 ADD COLUMN IF NOT EXISTS 가 없어 +-- information_schema 를 보고 없을 때만 실행한다. + +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'mission_progress' + AND column_name = 'reward_value_snapshot' +); + +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE mission_progress + ADD COLUMN reward_type_snapshot VARCHAR(20) NULL, + ADD COLUMN reward_value_snapshot INT NULL', + 'SELECT 1'); + +PREPARE add_reward_snapshot FROM @ddl; +EXECUTE add_reward_snapshot; +DEALLOCATE PREPARE add_reward_snapshot; diff --git a/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql b/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql new file mode 100644 index 00000000..a8733fc3 --- /dev/null +++ b/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql @@ -0,0 +1,26 @@ +-- 미션 제목과 설명 문구 정리. +-- +-- V30 파일을 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 문구 변경은 새 마이그레이션의 UPDATE 로 얹는다. UPDATE 는 몇 번을 다시 돌려도 결과가 같다. +-- +-- 설명은 전부 "~하기" 로 끝나는 행동 문구로 맞췄다. 제목은 한글 수사로 통일하고, +-- 무엇을 하라는 건지 제목만으로 안 읽히던 것들을 동사형으로 바꿨다. +-- 정확하게 -> 세 문제 맞히기 (무엇을 정확하게 하라는 건지 읽히지 않았다) +-- 꾸준함 -> 닷새 접속하기 (같은 이유) +-- 세트 완주 -> 복습 세트 완주 (무엇의 세트인지 분명해진다) +-- 서른 번의 복습 -> 서른 문제 복습 (설명이 "오답 30문제" 라 단위를 맞춘다) + +UPDATE mission_definition SET description = '앱 접속하기' WHERE code = 'DAILY_ATTEND'; +UPDATE mission_definition SET description = '오답노트 1개 쓰기' WHERE code = 'DAILY_NOTE_WRITE'; +UPDATE mission_definition SET description = '오답 3문제 복습하기' WHERE code = 'DAILY_REVIEW_3'; +UPDATE mission_definition SET description = '복습에서 3문제 맞히기' WHERE code = 'DAILY_CORRECT_3'; +UPDATE mission_definition SET description = '복습 세트 1개 끝내기' WHERE code = 'DAILY_PRACTICE_SET'; +UPDATE mission_definition SET description = '오늘 기분 남기기' WHERE code = 'DAILY_MOOD'; +UPDATE mission_definition SET description = '이번 주 5일 접속하기' WHERE code = 'WEEKLY_ATTEND_5'; +UPDATE mission_definition SET description = '오답노트 10개 쓰기' WHERE code = 'WEEKLY_NOTE_10'; +UPDATE mission_definition SET description = '오답 30문제 복습하기' WHERE code = 'WEEKLY_REVIEW_30'; + +UPDATE mission_definition SET title = '세 문제 맞히기' WHERE code = 'DAILY_CORRECT_3'; +UPDATE mission_definition SET title = '복습 세트 완주' WHERE code = 'DAILY_PRACTICE_SET'; +UPDATE mission_definition SET title = '닷새 접속하기' WHERE code = 'WEEKLY_ATTEND_5'; +UPDATE mission_definition SET title = '서른 문제 복습' WHERE code = 'WEEKLY_REVIEW_30'; diff --git a/src/main/resources/db/migration/V34__create_cosmetic_system.sql b/src/main/resources/db/migration/V34__create_cosmetic_system.sql new file mode 100644 index 00000000..861f72b3 --- /dev/null +++ b/src/main/resources/db/migration/V34__create_cosmetic_system.sql @@ -0,0 +1,65 @@ +-- 꾸미기(코스메틱) 1차: 아이템 카탈로그와 사용자별 장착 상태. 이 파일은 DDL 만 담는다. +-- 시드는 V35 에 따로 둔다. V29 와 같은 이유다. MySQL DDL 은 트랜잭션이 아니라서, 테이블은 만들어지고 +-- 뒤이은 INSERT 에서 끊기면 Flyway 는 실패로 기록하는데 테이블은 남는다. 재기동하면 +-- "table already exists" 로 또 실패해 flyway repair 없이는 앱이 뜨지 않는다. +-- +-- 같은 이유로 두 테이블 모두 IF NOT EXISTS 로 만들고, 인덱스도 별도 CREATE INDEX 가 아니라 +-- 테이블 정의 안에 둔다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 +-- 그 문장 자체가 새로운 재실행 지점이 되기 때문이다(V31 이 information_schema 가드를 쓴 이유가 그거다). +-- 새로 만드는 테이블은 가드가 아예 필요 없는 형태로 쓰는 편이 낫다. 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. + +-- cosmetic_item: 꾸미기 아이템 카탈로그. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. +-- +-- required_level 이 NULL 이면 "레벨로는 열리지 않는다"는 뜻이다. 0 이나 -1 같은 마법값을 쓰지 않는다. +-- 보유 여부는 저장하지 않고 required_level 과 사용자 레벨을 비교해 매번 계산한다. +-- +-- conflicts_with 는 콤마로 구분한 item_key 목록이다. 지금은 전부 비어 있지만 컬럼과 처리 로직을 +-- 먼저 만들어 둔다. 후드 옷과 모자처럼 같이 못 쓰는 조합이 나오면 데이터만 채우면 된다. +-- +-- set_id 는 세트 장착(PUT /api/cosmetics/equip-set)이 묶어서 거는 단위다. +CREATE TABLE IF NOT EXISTS cosmetic_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + item_key VARCHAR(64) NOT NULL, + slot VARCHAR(32) NOT NULL, + name_ko VARCHAR(64) NOT NULL, + image_url VARCHAR(512) NOT NULL, + required_level INT NULL, + set_id VARCHAR(64) NULL, + conflicts_with VARCHAR(512) NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + -- 장착 요청과 레이아웃 저장이 모두 item_key 로 아이템을 가리킨다. 중복이 생기면 어느 쪽을 + -- 가리키는지 알 수 없어지므로 정합성용 유니크 키다. 시드의 재실행 안전성도 이 키가 받쳐 준다. + UNIQUE KEY uk_cosmetic_item_key (item_key), + -- 레벨업 해금 알림이 "levelBefore < required_level <= levelAfter 인 활성 아이템" 을 찾는다. + KEY idx_cosmetic_item_active_level (active, required_level), + KEY idx_cosmetic_item_set (set_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- user_cosmetic_loadout: 사용자가 슬롯별로 무엇을 걸고 있는지. +-- +-- **(user_id, slot) 복합 기본키가 이 설계의 핵심이다.** 한 슬롯에 두 개가 들어가는 것을 +-- 애플리케이션 검사가 아니라 DB 가 막는다. 장착은 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 +-- 처리하는데, 그 upsert 가 성립하는 근거가 바로 이 기본키다. +-- 같은 사용자가 같은 슬롯에 두 아이템을 동시에 걸어도 중복 행이 생길 수 없고, +-- "읽어서 있으면 UPDATE 없으면 INSERT" 로 갈랐을 때 나는 유니크 충돌 예외 경로도 없다. +-- +-- 대리키(id)를 두지 않는다. 대리키를 두면 유니크 키는 별개의 보조 인덱스가 되고, 조회가 +-- (user_id, slot) 로 들어오는데 정작 클러스터드 인덱스는 쓸모없는 id 가 된다. +-- 여기서는 자연키가 곧 조회 키라 그대로 기본키로 두는 편이 읽기도 쓰기도 싸다. +-- +-- user 에 외래키를 걸지 않는다. mission_progress 와 같은 이유로, INSERT 마다 부모 사용자 행에 +-- 공유 잠금이 붙으면 이미 사용자 행을 배타 잠금으로 잡는 미션 보상 지급 경로와 잠금 순서가 엇갈린다. +-- +-- item_key 에 '__none__' 이 들어간 행은 "이 슬롯을 일부러 비웠다"는 표시다. 자세한 설명은 +-- UserCosmeticLoadout 엔티티 주석에 있다. 행을 아예 지우면 "한 번도 안 건드린 사용자"와 +-- 구별되지 않아 기본 프리셋이 되살아난다. +CREATE TABLE IF NOT EXISTS user_cosmetic_loadout ( + user_id BIGINT NOT NULL, + slot VARCHAR(32) NOT NULL, + item_key VARCHAR(64) NOT NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (user_id, slot) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V35__seed_cosmetic_items.sql b/src/main/resources/db/migration/V35__seed_cosmetic_items.sql new file mode 100644 index 00000000..a1233ae0 --- /dev/null +++ b/src/main/resources/db/migration/V35__seed_cosmetic_items.sql @@ -0,0 +1,78 @@ +-- 꾸미기 아이템 시드. 관리자 화면은 2차라 카탈로그는 마이그레이션으로만 들어온다. +-- +-- ON DUPLICATE KEY UPDATE item_key = item_key 는 "이미 있으면 아무것도 하지 않는다"는 뜻이다. +-- uk_cosmetic_item_key 가 중복을 잡아 주므로 이 문장은 몇 번을 다시 돌려도 결과가 같다. +-- 운영 중에 이름이나 이미지를 손으로 고쳤을 수 있어 덮어쓰지 않는다. 값을 바꾸려면 새 마이그레이션에서 +-- UPDATE 로 얹는다(V33 이 미션 문구를 그렇게 고쳤다). 이 파일을 고치면 이미 적용된 환경에서 +-- Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- +-- **image_url 은 지금 번들 상대 경로다.** S3 에 아직 아무것도 올라가 있지 않다. +-- 'assets/Cosmetic/hat_beanie.png' 처럼 앱 번들 경로로 시드해 두면, 나중에 S3 로 옮길 때 +-- 이 컬럼 값만 'https://...' 로 바꾸는 UPDATE 한 번으로 전환된다. 앱을 다시 배포할 필요가 없다. +-- 프론트는 값이 'http' 로 시작하는지로 네트워크 이미지와 번들 이미지를 갈라 처리하기로 했다. +-- +-- 'BASE' 행은 장착 슬롯이 아니라 개구리 본체 이미지다. 슬롯 이름을 'BASE' 로 둬서 +-- 장착 가능한 슬롯 목록에서 빠지고 아이템 목록에도 나가지 않는다. 본체 이미지까지 데이터로 두는 이유는 +-- 위와 같다. S3 전환이 코드 배포 없이 끝나야 한다. +-- +-- required_level 이 NULL 인 행은 레벨로는 열리지 않는다. active = 0 인 2차 콘텐츠가 전부 그렇다. +-- 이미지를 다 그리고 나면 active 를 1 로 올리고 required_level 을 채우는 마이그레이션을 새로 쓴다. +-- +-- conflicts_with 는 전부 비워 둔다. 컬럼과 자동 해제 로직은 이미 있으니, 같이 못 쓰는 조합이 +-- 생기면 데이터만 채우면 된다. + +-- 개구리 본체. 장착 대상이 아니다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('BASE', 'BASE', '개구리 본체', 'assets/Cosmetic/BASE.png', NULL, NULL, NULL, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; + +-- 레벨 해금 아이템. 총 학습 레벨 2 부터 15 까지 한 단계에 하나씩, 마지막 15 는 졸업 세트 세 점이다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('headband_sprout', 'HEAD', '새싹 머리띠', 'assets/Cosmetic/headband_sprout.png', 2, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_spring', 'BACKGROUND', '봄 배경', 'assets/Cosmetic/bg_spring.png', 3, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_round', 'FACE', '동그란 안경', 'assets/Cosmetic/glasses_round.png', 4, NULL, NULL, 1, NOW(6), NOW(6)), + ('scarf', 'NECK', '목도리', 'assets/Cosmetic/scarf.png', 5, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beanie', 'HEAD', '비니', 'assets/Cosmetic/hat_beanie.png', 6, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_study', 'BACKGROUND', '공부방 배경', 'assets/Cosmetic/bg_study.png', 7, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_mini_backpack', 'BACK', '미니 백팩', 'assets/Cosmetic/bag_mini_backpack.png', 8, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_cardigan', 'OUTFIT', '가디건', 'assets/Cosmetic/outfit_cardigan.png', 9, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_study', 'HAND', '공부 도구', 'assets/Cosmetic/prop_study.png', 10, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_sun', 'FACE', '선글라스', 'assets/Cosmetic/glasses_sun.png', 11, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_bucket', 'HEAD', '버킷햇', 'assets/Cosmetic/hat_bucket.png', 12, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_night', 'BACKGROUND', '밤하늘 배경', 'assets/Cosmetic/bg_night.png', 13, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_crown', 'HEAD', '왕관', 'assets/Cosmetic/hat_crown.png', 14, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_graduate', 'HEAD', '학사모', 'assets/Cosmetic/hat_graduate.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)), + ('outfit_graduate', 'OUTFIT', '졸업 가운', 'assets/Cosmetic/outfit_graduate.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)), + ('prop_diploma', 'HAND', '졸업장', 'assets/Cosmetic/prop_diploma.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; + +-- 2차 콘텐츠. 이미지가 아직 없어 active = 0 으로 넣어 둔다. +-- 지금 넣는 이유는 item_key 를 여기서 확정해 두기 위해서다. 프론트 에셋 이름과 어긋나면 +-- 나중에 둘 중 하나를 고쳐야 하는데, 이미 깔린 앱이 있으면 그때는 못 고친다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('hat_beret', 'HEAD', '베레모', 'assets/Cosmetic/hat_beret.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('headphone', 'HEAD', '헤드폰', 'assets/Cosmetic/headphone.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('glasses_heart', 'FACE', '하트 안경', 'assets/Cosmetic/glasses_heart.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bowtie', 'NECK', '나비넥타이', 'assets/Cosmetic/bowtie.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('neck_medal', 'NECK', '메달', 'assets/Cosmetic/neck_medal.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('neck_camera', 'NECK', '카메라', 'assets/Cosmetic/neck_camera.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_school', 'OUTFIT', '교복', 'assets/Cosmetic/outfit_school.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_hoodie', 'OUTFIT', '후드티', 'assets/Cosmetic/outfit_hoodie.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_raincoat', 'OUTFIT', '우비', 'assets/Cosmetic/outfit_raincoat.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bag_waist_pouch', 'BACK', '허리 가방', 'assets/Cosmetic/bag_waist_pouch.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bag_crossbody_satchel', 'BACK', '크로스백', 'assets/Cosmetic/bag_crossbody_satchel.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('prop_bouquet', 'HAND', '꽃다발', 'assets/Cosmetic/prop_bouquet.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('prop_umbrella', 'HAND', '우산', 'assets/Cosmetic/prop_umbrella.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('badge_leaf_star', 'BADGE', '잎새 별 뱃지', 'assets/Cosmetic/badge_leaf_star.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_autumn', 'BACKGROUND', '가을 배경', 'assets/Cosmetic/bg_autumn.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_rainy', 'BACKGROUND', '비 오는 날 배경', 'assets/Cosmetic/bg_rainy.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_space', 'BACKGROUND', '우주 배경', 'assets/Cosmetic/bg_space.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_sunset', 'BACKGROUND', '노을 배경', 'assets/Cosmetic/bg_sunset.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_winter', 'BACKGROUND', '겨울 배경', 'assets/Cosmetic/bg_winter.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; diff --git a/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql b/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql new file mode 100644 index 00000000..0990e6ab --- /dev/null +++ b/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql @@ -0,0 +1,61 @@ +-- 꾸미기 해금을 능력치별로 바꾸기 위한 컬럼 셋. 이 파일은 DDL 만 담는다. 시드 교체는 V37 이다. +-- +-- V34/V35 를 직접 고치지 않는다. 이 브랜치는 아직 push 도 배포도 안 됐지만, 로컬 DB 에는 이미 +-- 적용돼 있을 수 있다. 적용된 파일을 고치면 Flyway 체크섬이 어긋나 `flyway repair` 없이는 앱이 뜨지 않는다. +-- 새 파일로 얹으면 이미 V35 까지 돈 DB 도, 처음부터 도는 DB 도 같은 결과에 도달한다. +-- +-- MySQL 에는 ADD COLUMN IF NOT EXISTS 가 없다. DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 +-- 커넥션이 끊기면 재기동 때 이 파일이 다시 돌면서 Duplicate column name 으로 앱이 뜨지 않는다. +-- V31 과 같은 방식으로 information_schema 를 보고 없을 때만 실행한다. 컬럼마다 따로 보는 이유는, +-- 하나로 묶으면 "세 개 중 하나만 들어간" 상태에서 빠져나올 길이 없기 때문이다. + +-- required_ability: 이 아이템의 required_level 을 어느 레벨과 비교할지. +-- 값이 있으면 그 능력치 레벨, NULL 이면 총 학습 레벨이다. +-- 값은 MissionType.AbilityType enum 그대로다: ATTENDANCE, NOTE_WRITE, PROBLEM_PRACTICE, NOTE_PRACTICE. +-- 기본값을 두지 않는다. "능력치 조건이 없다"를 NULL 하나로만 표현해야 required_level 의 NULL 규칙과 어긋나지 않는다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'required_ability' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN required_ability VARCHAR(32) NULL AFTER required_level', + 'SELECT 1'); +PREPARE add_required_ability FROM @ddl; +EXECUTE add_required_ability; +DEALLOCATE PREPARE add_required_ability; + +-- full_body: 소매와 바짓단까지 그려진 전신 의상인지. +-- 앱이 이 옷을 입히면 개구리 본체를 머리만 있는 그림으로 바꿔 깐다. 그러지 않으면 옷 밑으로 팔다리가 삐져나온다. +-- NOT NULL DEFAULT FALSE 라 기존 행은 전부 false 로 채워진다. V37 이 해당하는 옷만 true 로 올린다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'full_body' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN full_body TINYINT(1) NOT NULL DEFAULT 0 AFTER required_ability', + 'SELECT 1'); +PREPARE add_full_body FROM @ddl; +EXECUTE add_full_body; +DEALLOCATE PREPARE add_full_body; + +-- set_name_ko: 세트의 사람이 읽는 이름. set_id 는 기계용 키라 화면에 그대로 쓸 수 없다. +-- 이 컬럼이 없던 동안 프론트가 세트 배너에 아이템 이름을 이어 붙여 썼다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'set_name_ko' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN set_name_ko VARCHAR(64) NULL AFTER set_id', + 'SELECT 1'); +PREPARE add_set_name_ko FROM @ddl; +EXECUTE add_set_name_ko; +DEALLOCATE PREPARE add_set_name_ko; diff --git a/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql b/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql new file mode 100644 index 00000000..0a9a1435 --- /dev/null +++ b/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql @@ -0,0 +1,113 @@ +-- 꾸미기 시드 교체: 총 학습 레벨 하나로 열리던 카탈로그를 능력치별 해금표로 바꾼다. +-- +-- V35 를 직접 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 값 변경은 새 마이그레이션으로 얹는다(V33 이 미션 문구를 그렇게 고쳤다). +-- +-- 이 파일은 V35 와 달리 **기존 행을 덮어쓴다.** V35 의 ON DUPLICATE KEY UPDATE 는 +-- item_key = item_key 라 "있으면 아무것도 안 한다" 였는데, 여기서는 해금 조건 자체를 갈아엎는 것이 목적이라 +-- 덮어쓰지 않으면 V35 까지 돈 DB 와 처음부터 도는 DB 의 결과가 달라진다. +-- 덮어쓰는 컬럼은 카탈로그가 정하는 것뿐이다. conflicts_with 와 created_at 은 손대지 않는다. +-- 충돌 목록은 운영에서 채워 넣는 값이라 시드가 되돌리면 안 된다. +-- 몇 번을 다시 돌려도 결과가 같다. +-- +-- 해금 기준이 둘로 갈린다. +-- required_ability 가 있으면 -> 그 능력치 레벨과 required_level 을 비교한다 +-- required_ability 가 NULL 이면 -> 총 학습 레벨과 비교한다 (V35 까지의 동작) +-- 능력치별로 가는 이유는 셋이다. 테마 해금이 이미 능력치별로 돌고 있고(ThemeLockManager), +-- 앱에 능력치 넷을 보여 주는 스탯 화면이 새로 생겼는데 보상이 총 레벨만 보면 그 화면이 죽고, +-- "출석 Lv.5 달성" 같은 구체적인 조건을 사용자에게 보여 줄 수 있다. +-- +-- 자리와 레이어 순서(작을수록 뒤에 깔린다): +-- BACKGROUND 100 배경 / BACK 200 등짐 / BASE 300 본체 / OUTFIT 400 옷 / BAG 450 가방 +-- NECK 500 목 / FACE 600 얼굴 / HEAD 700 머리 / HAND 800 손 / BADGE 850 뱃지 / EFFECT 900 효과 +-- **BACK 의 뜻이 V35 에서 바뀌었다.** V35 의 BACK 은 그냥 '가방' 이었고 미니 백팩·허리 가방·크로스백이 +-- 거기 있었다. 이제 BACK 은 등에 메는 것(본체보다 뒤), BAG 은 앞으로 메는 것(옷 위)이다. +-- 그래서 저 셋은 BAG 으로 옮겨 간다. 등에 메는 백팩 둘이 BACK 으로 새로 들어온다. +-- +-- 2차 콘텐츠로 active = 0 이던 19 개는 전부 해금 레벨을 받아 active = 1 이 된다. +-- image_url 은 여전히 앱 번들 상대 경로다. S3 로 옮길 때 이 컬럼만 바꾸면 앱 배포 없이 전환된다. +-- +-- 개구리 본체('BASE') 행은 건드리지 않는다. V35 가 넣은 그대로고 해금 조건이 없다. + +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, required_ability, full_body, + set_id, set_name_ko, conflicts_with, active, created_at, updated_at) +VALUES + -- 출석 (ATTENDANCE). 배경과 전경 효과가 여기 붙는다. 매일 들어오는 것으로 화면 분위기가 바뀐다. + ('bg_spring', 'BACKGROUND', '봄 배경', 'assets/Cosmetic/bg_spring.png', 2, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_petals', 'EFFECT', '꽃잎 효과', 'assets/Cosmetic/effect_petals.png', 3, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_summer', 'BACKGROUND', '여름 배경', 'assets/Cosmetic/bg_summer.png', 4, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_sparkle', 'EFFECT', '반짝임 효과', 'assets/Cosmetic/effect_sparkle.png', 5, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_rainy', 'BACKGROUND', '비 오는 날 배경', 'assets/Cosmetic/bg_rainy.png', 6, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_fireflies', 'EFFECT', '반딧불 효과', 'assets/Cosmetic/effect_fireflies.png', 8, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_autumn', 'BACKGROUND', '가을 배경', 'assets/Cosmetic/bg_autumn.png', 9, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_sunset', 'BACKGROUND', '노을 배경', 'assets/Cosmetic/bg_sunset.png', 11, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_winter', 'BACKGROUND', '겨울 배경', 'assets/Cosmetic/bg_winter.png', 12, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_snow', 'EFFECT', '눈 내리는 효과', 'assets/Cosmetic/effect_snow.png', 13, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_night', 'BACKGROUND', '밤하늘 배경', 'assets/Cosmetic/bg_night.png', 14, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_space', 'BACKGROUND', '우주 배경', 'assets/Cosmetic/bg_space.png', 15, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 오답노트 작성 (NOTE_WRITE). 가방과 손에 드는 것. 쓰는 사람의 짐이 늘어나는 쪽으로 묶었다. + ('bag_mini_backpack', 'BAG', '미니 백팩', 'assets/Cosmetic/bag_mini_backpack.png', 2, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_notebook', 'HAND', '공책', 'assets/Cosmetic/prop_notebook.png', 3, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('back_backpack_navy', 'BACK', '네이비 백팩', 'assets/Cosmetic/back_backpack_navy.png', 5, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_study', 'HAND', '공부 도구', 'assets/Cosmetic/prop_study.png', 6, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_waist_pouch', 'BAG', '허리 가방', 'assets/Cosmetic/bag_waist_pouch.png', 8, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('back_backpack_canvas', 'BACK', '캔버스 백팩', 'assets/Cosmetic/back_backpack_canvas.png', 9, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_crossbody_satchel', 'BAG', '크로스백', 'assets/Cosmetic/bag_crossbody_satchel.png', 11, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_study', 'BACKGROUND', '공부방 배경', 'assets/Cosmetic/bg_study.png', 12, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_tumbler', 'HAND', '텀블러', 'assets/Cosmetic/prop_tumbler.png', 13, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 문제 복습 (PROBLEM_PRACTICE). 얼굴과 머리. 안경처럼 '보는 것' 이 복습과 어울린다. + ('glasses_round', 'FACE', '동그란 안경', 'assets/Cosmetic/glasses_round.png', 2, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beanie', 'HEAD', '비니', 'assets/Cosmetic/hat_beanie.png', 4, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_cheek_stickers', 'FACE', '볼 스티커', 'assets/Cosmetic/face_cheek_stickers.png', 5, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_sun', 'FACE', '선글라스', 'assets/Cosmetic/glasses_sun.png', 6, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_bucket', 'HEAD', '버킷햇', 'assets/Cosmetic/hat_bucket.png', 8, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_eye_patch', 'FACE', '안대', 'assets/Cosmetic/face_eye_patch.png', 9, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beret', 'HEAD', '베레모', 'assets/Cosmetic/hat_beret.png', 10, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_heart', 'FACE', '하트 안경', 'assets/Cosmetic/glasses_heart.png', 12, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('headphone', 'HEAD', '헤드폰', 'assets/Cosmetic/headphone.png', 13, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_moustache', 'FACE', '콧수염', 'assets/Cosmetic/face_moustache.png', 14, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('head_earmuffs_winter', 'HEAD', '겨울 귀마개', 'assets/Cosmetic/head_earmuffs_winter.png', 15, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 복습 세트 복습 (NOTE_PRACTICE). 목과 옷. 옷 넷은 전신이라 본체 그림이 머리만 남는다. + ('scarf', 'NECK', '목도리', 'assets/Cosmetic/scarf.png', 2, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bowtie', 'NECK', '나비넥타이', 'assets/Cosmetic/bowtie.png', 3, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_cardigan', 'OUTFIT', '가디건', 'assets/Cosmetic/outfit_cardigan.png', 5, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_scarf_coral', 'NECK', '산호빛 스카프', 'assets/Cosmetic/neck_scarf_coral.png', 6, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_hoodie', 'OUTFIT', '후드티', 'assets/Cosmetic/outfit_hoodie.png', 8, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_camera', 'NECK', '카메라', 'assets/Cosmetic/neck_camera.png', 9, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_raincoat', 'OUTFIT', '우비', 'assets/Cosmetic/outfit_raincoat.png', 10, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_medal', 'NECK', '메달', 'assets/Cosmetic/neck_medal.png', 12, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_school', 'OUTFIT', '교복', 'assets/Cosmetic/outfit_school.png', 13, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 총 학습 레벨 (required_ability = NULL). 능력치 넷을 골고루 올려야 오르는 값이라 + -- 한쪽만 파는 사람에게는 늦게 열린다. 뱃지와 손에 드는 것, 그리고 마지막 학사 세트를 둔다. + -- 상한이 15 에서 20 으로 올라가 16 / 18 / 19 / 20 자리가 생겼다. + ('headband_sprout', 'HEAD', '새싹 머리띠', 'assets/Cosmetic/headband_sprout.png', 2, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_leaf_star', 'BADGE', '잎새 별 뱃지', 'assets/Cosmetic/badge_leaf_star.png', 3, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_star', 'BADGE', '별 뱃지', 'assets/Cosmetic/badge_star.png', 5, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_heart', 'BADGE', '하트 뱃지', 'assets/Cosmetic/badge_heart.png', 7, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_bouquet', 'HAND', '꽃다발', 'assets/Cosmetic/prop_bouquet.png', 8, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_music', 'BADGE', '음표 뱃지', 'assets/Cosmetic/badge_music.png', 10, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_umbrella', 'HAND', '우산', 'assets/Cosmetic/prop_umbrella.png', 12, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_flame', 'BADGE', '불꽃 뱃지', 'assets/Cosmetic/badge_flame.png', 14, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_lantern', 'HAND', '랜턴', 'assets/Cosmetic/prop_lantern.png', 16, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_snowflake', 'BADGE', '눈송이 뱃지', 'assets/Cosmetic/badge_snowflake.png', 18, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_crown', 'HEAD', '왕관', 'assets/Cosmetic/hat_crown.png', 19, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_graduate', 'HEAD', '학사모', 'assets/Cosmetic/hat_graduate.png', 20, NULL, 0, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)), + ('outfit_graduate', 'OUTFIT', '졸업 가운', 'assets/Cosmetic/outfit_graduate.png', 20, NULL, 1, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)), + ('prop_diploma', 'HAND', '졸업장', 'assets/Cosmetic/prop_diploma.png', 20, NULL, 0, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)) + +ON DUPLICATE KEY UPDATE + slot = VALUES(slot), + name_ko = VALUES(name_ko), + image_url = VALUES(image_url), + required_level = VALUES(required_level), + required_ability = VALUES(required_ability), + full_body = VALUES(full_body), + set_id = VALUES(set_id), + set_name_ko = VALUES(set_name_ko), + active = VALUES(active), + updated_at = NOW(6); diff --git a/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql b/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql new file mode 100644 index 00000000..546e1494 --- /dev/null +++ b/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql @@ -0,0 +1,52 @@ +-- 프로필 프레임 8종을 치장 카탈로그에 편입한다. 컬럼 추가는 없고 시드만 늘어난다. +-- +-- V37 을 직접 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 기존 55 줄은 해금표에서 한 줄도 바뀌지 않았으므로 여기서는 덮어쓸 것이 없다. +-- 그래도 V37 과 같은 ON DUPLICATE KEY UPDATE 절을 그대로 쓴다. 이 파일이 다시 돌 때 +-- 행이 카탈로그가 정한 값으로 수렴한다는 성질이 두 파일에서 같아야 하기 때문이다. +-- conflicts_with 와 created_at 은 V37 과 같은 이유로 손대지 않는다. +-- +-- **FRAME 은 개구리에 겹치지 않는다.** 원형 프로필 사진의 테두리라 개구리 합성에서는 빠지고 +-- 프로필 위젯이 따로 쓴다. layer_order 1000 은 옷장에서의 자리 순서를 정하려고 둔 값이지 +-- 개구리 위에 그린다는 뜻이 아니다. 그 구분은 응답의 slots[].composited = false 가 한다 +-- (CosmeticSlot.FRAME 주석 참고). 목록에서 빼지는 않는다. 옷장에는 보여 줘야 한다. +-- +-- **에셋 경로가 혼자 다르다.** 나머지 치장은 assets/Cosmetic/{item_key}.png 인데 +-- 프레임만 assets/ProfileFrame/{item_key}.svg 다. 원형 테두리라 확대해도 깨지면 안 돼서 SVG 다. +-- image_url 한 컬럼이 경로와 확장자를 다 들고 있어 이 차이가 코드로 새지 않는다. +-- S3 로 옮길 때도 이 컬럼만 바꾸면 되는 것은 같다. +-- +-- 해금 자리는 계절 배경과 짝을 맞췄다. 봄 배경(출석 2) 다음 레벨에 봄 프레임(출석 3) 하는 식이라 +-- 한 능력치 안에서 같은 레벨에 두 개가 열리는 자리가 생긴다(출석 3 / 5 / 13 / 15). +-- 슬롯이 서로 달라 기본 프리셋이 흔들리지는 않는다. + +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, required_ability, full_body, + set_id, set_name_ko, conflicts_with, active, created_at, updated_at) +VALUES + -- 출석 (ATTENDANCE). 계절 배경 바로 다음 레벨에 같은 계절 프레임을 둔다. + ('frame_spring', 'FRAME', '봄 프레임', 'assets/ProfileFrame/frame_spring.svg', 3, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_summer', 'FRAME', '여름 프레임', 'assets/ProfileFrame/frame_summer.svg', 5, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_autumn', 'FRAME', '가을 프레임', 'assets/ProfileFrame/frame_autumn.svg', 10, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_winter', 'FRAME', '겨울 프레임', 'assets/ProfileFrame/frame_winter.svg', 13, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_night', 'FRAME', '밤하늘 프레임', 'assets/ProfileFrame/frame_night.svg', 15, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 오답노트 작성 (NOTE_WRITE). 공부방 배경(작성 12) 뒤에 공부방 프레임을 둔다. + ('frame_study', 'FRAME', '공부방 프레임', 'assets/ProfileFrame/frame_study.svg', 14, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 총 학습 레벨 (required_ability = NULL). 잎새는 잎새 별 뱃지 바로 뒤, + -- 마스터는 랜턴(16)과 눈송이 뱃지(18) 사이다. + ('frame_leaf', 'FRAME', '잎새 프레임', 'assets/ProfileFrame/frame_leaf.svg', 4, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_master', 'FRAME', '마스터 프레임', 'assets/ProfileFrame/frame_master.svg', 17, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)) + +ON DUPLICATE KEY UPDATE + slot = VALUES(slot), + name_ko = VALUES(name_ko), + image_url = VALUES(image_url), + required_level = VALUES(required_level), + required_ability = VALUES(required_ability), + full_body = VALUES(full_body), + set_id = VALUES(set_id), + set_name_ko = VALUES(set_name_ko), + active = VALUES(active), + updated_at = NOW(6); diff --git a/src/main/resources/db/migration/V39__rename_back_slot_item_names.sql b/src/main/resources/db/migration/V39__rename_back_slot_item_names.sql new file mode 100644 index 00000000..a298784f --- /dev/null +++ b/src/main/resources/db/migration/V39__rename_back_slot_item_names.sql @@ -0,0 +1,22 @@ +-- 등에 메는 가방(BACK) 자리의 아이템 이름 정리. 사람이 읽는 이름만 바꾼다. +-- +-- 자리 이름이 '등짐' 에서 '배낭' 으로 바뀌었다. 어휘가 어색하다는 이야기가 있었고, +-- '배낭' 이 등에 멘다는 뜻이 정확하면서 앞으로 메는 BAG(450) 의 '가방' 과도 안 겹친다. +-- 자리 이름 자체는 CosmeticSlot.BACK 의 nameKo 라 코드에 있고, 여기서는 아이템 이름만 맞춘다. +-- +-- '백팩' 을 '배낭' 으로 바꾸는 이유는 BAG 자리에 '미니 백팩'(bag_mini_backpack)이 있기 때문이다. +-- 자리는 '배낭' 인데 아이템은 '캔버스 백팩' 이면, 앞으로 메는 '미니 백팩' 과 이름만으로는 구별이 안 된다. +-- +-- **item_key 와 slot 값은 건드리지 않는다.** 프론트 에셋 파일명과 해금표가 그 키로 맞춰져 있다. +-- 바뀌는 것은 name_ko 두 줄뿐이다. +-- +-- V37 을 고치지 않고 새 파일로 얹는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 값 변경은 새 마이그레이션의 UPDATE 로 얹는다(V33 이 미션 문구를 그렇게 고쳤다). +-- UPDATE 는 몇 번을 다시 돌려도 결과가 같다. +-- +-- 주의: V37 과 V38 은 ON DUPLICATE KEY UPDATE 절에 name_ko = VALUES(name_ko) 를 달고 있다. +-- 앞으로 카탈로그를 통째로 다시 시드하는 파일을 쓴다면 거기에 이 새 이름을 담아야 한다. +-- 옛 이름을 담은 채로 그런 파일이 V39 뒤에 돌면 이 UPDATE 가 그대로 되돌아간다. + +UPDATE cosmetic_item SET name_ko = '남색 배낭' WHERE item_key = 'back_backpack_navy'; +UPDATE cosmetic_item SET name_ko = '캔버스 배낭' WHERE item_key = 'back_backpack_canvas'; diff --git a/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql b/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql new file mode 100644 index 00000000..c529c44d --- /dev/null +++ b/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql @@ -0,0 +1,29 @@ +-- 아이템이 자리의 그리는 층을 덮어쓸 수 있게 하는 컬럼. 이 파일은 DDL 만 담는다. 값 채우기는 V41 이다. +-- +-- 가방 자리를 BACK(200) 과 BAG(450) 둘로 나눠 뒀는데 각각 2개·3개뿐이라 탭을 나눌 만큼이 아니었고, +-- 사용자에게 "왜 가방 자리가 둘이지" 를 설명해야 하는 구조였다. 그래서 BAG 하나로 합친다. +-- +-- 문제는 그리는 층이다. 배낭은 개구리 뒤(200), 앞가방은 옷 위(450). 자리를 합치면서 층까지 통일하면 +-- 배낭이 개구리 앞으로 나와 이상해진다. 자리는 하나로 두되 그 둘만 층을 따로 갖게 한다. +-- +-- layer_order 가 NULL 이면 자리 값(CosmeticSlot.layerOrder)을 쓴다. 0 같은 마법값을 쓰지 않는다. +-- required_level 과 같은 이유다. "덮어쓰지 않는다" 와 "0층에 그린다" 는 다른 말이다. +-- 프론트는 item.layerOrder ?? slot.layerOrder 로 푼다. +-- +-- MySQL 에 ADD COLUMN IF NOT EXISTS 가 없다. DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 +-- 커넥션이 끊기면 재기동 때 이 파일이 다시 돌면서 Duplicate column name 으로 앱이 뜨지 않는다. +-- V36 과 같은 방식으로 information_schema 를 보고 없을 때만 실행한다. + +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'layer_order' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN layer_order INT NULL AFTER slot', + 'SELECT 1'); +PREPARE add_layer_order FROM @ddl; +EXECUTE add_layer_order; +DEALLOCATE PREPARE add_layer_order; diff --git a/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql b/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql new file mode 100644 index 00000000..21c37599 --- /dev/null +++ b/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql @@ -0,0 +1,43 @@ +-- 가방 자리 통합. 카탈로그의 BACK 두 줄을 BAG 으로 옮기고, 그 둘만 그리는 층 200 을 직접 갖게 한다. +-- 그리고 이미 BACK 자리에 걸어 둔 사용자 행을 옮긴다. +-- +-- V37/V39 를 고치지 않고 새 파일로 얹는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 여기는 전부 UPDATE/DELETE 라 몇 번을 다시 돌려도 결과가 같다. +-- +-- **item_key 와 name_ko 는 건드리지 않는다.** 에셋 파일명과 해금표가 item_key 로 맞춰져 있고, +-- 이름(남색 배낭 / 캔버스 배낭)은 한 탭 안에서 이게 등에 메는 것인지 앞으로 메는 것인지 말해 준다. +-- 자리가 하나가 됐으니 이름이 그 구분을 대신 진다. +-- +-- 주의: V37 은 ON DUPLICATE KEY UPDATE 절에 slot = VALUES(slot) 을 달고 있다. 앞으로 카탈로그를 +-- 통째로 다시 시드하는 파일을 쓴다면 거기에 BAG 과 layer_order 를 담아야 한다. V39 와 같은 사정이다. + +-- ─────────────── 1. 카탈로그 ─────────────── + +-- 배낭 둘만 자리 기본값(450)을 덮어쓴다. 나머지는 NULL 그대로라 자리 값을 쓴다. +UPDATE cosmetic_item SET slot = 'BAG', layer_order = 200 +WHERE item_key IN ('back_backpack_navy', 'back_backpack_canvas'); + +-- ─────────────── 2. 사용자 장착 행 ─────────────── +-- +-- BACK 은 이제 CosmeticSlot 에 없는 값이다. 행을 남겨 두면 조회할 때 엔티티 매핑이 +-- IllegalArgumentException 으로 터져 꾸미기 화면이 통째로 안 열린다. 반드시 비워야 한다. +-- +-- 지우지 않고 옮기는 이유는 사용자가 실제로 고른 것이기 때문이다. 지우면 배낭을 메고 있던 사용자가 +-- 다음 조회에서 이유 없이 맨등이 된다. +-- +-- 한 사용자가 BACK 과 BAG 을 둘 다 갖고 있으면 하나만 남길 수 있다((user_id, slot) 기본키). +-- **BAG 쪽을 남긴다.** 살아남는 자리의 행이라 그대로 두면 되고, 앞으로 메는 가방이 위에 그려져 +-- 더 눈에 띈다. updated_at 이 최신인 쪽을 남기는 규칙도 생각했지만 쓸 수 없다. +-- 기본 프리셋을 행으로 굳힐 때 모든 자리를 같은 NOW(6) 으로 쓰기 때문에, 대부분의 사용자는 +-- 두 행의 시각이 정확히 같아 시각으로는 아무것도 가릴 수 없다. + +-- 2-1. BAG 행이 이미 있는 사용자의 BACK 행을 버린다. +DELETE back FROM user_cosmetic_loadout back +JOIN user_cosmetic_loadout bag + ON bag.user_id = back.user_id AND bag.slot = 'BAG' +WHERE back.slot = 'BACK'; + +-- 2-2. 남은 BACK 행을 BAG 으로 옮긴다. 2-1 이 충돌할 상대를 전부 치웠으므로 기본키 충돌이 없다. +-- item_key '__none__'(일부러 비운 자리) 행도 그대로 옮긴다. 비워 둔 것도 사용자의 선택이고, +-- 지우면 "한 번도 안 건드린 사용자" 쪽으로 기울어 기본 프리셋이 되살아날 수 있다. +UPDATE user_cosmetic_loadout SET slot = 'BAG' WHERE slot = 'BACK'; diff --git a/src/main/resources/db/migration/V42__create_user_achievement.sql b/src/main/resources/db/migration/V42__create_user_achievement.sql new file mode 100644 index 00000000..fb5dc67e --- /dev/null +++ b/src/main/resources/db/migration/V42__create_user_achievement.sql @@ -0,0 +1,37 @@ +-- 훈장. 사용자가 어떤 훈장을 언제 받았는지만 담는다. +-- +-- 테이블이 하나뿐인 이유는 훈장 목록 자체를 코드의 enum(Achievement)으로 두기 때문이다. +-- 치장 아이템과 달리 훈장은 관리자가 늘리는 것이 아니라 앱 에셋과 함께 배포되는 것이라, +-- 카탈로그 테이블을 두면 시드 마이그레이션과 앱 번들이 따로 놀 여지만 생긴다. +-- 그래서 V34/V35 처럼 DDL 과 시드를 나눌 일도 없다. 이 파일에는 시드가 없다. +-- +-- **(user_id, achievement_key) 복합 기본키가 멱등성의 근거다.** 판정이 조회할 때마다 돌기 때문에 +-- 훈장 화면을 두 번 열면 같은 INSERT 가 두 번 나간다. 중복을 막는 것을 애플리케이션 검사에 맡기면 +-- 두 요청이 동시에 "없다"를 읽고 둘 다 INSERT 하는 창이 열린다. +-- 이 기본키가 있으면 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 끝나고, 그 창 자체가 없다. +-- +-- 대리키(id)를 두지 않는다. 조회는 언제나 user_id 로 들어오고 쓰기는 (user_id, achievement_key) 다. +-- 자연키가 곧 조회 키라 그대로 기본키로 두는 편이 읽기도 쓰기도 싸다. user_cosmetic_loadout 과 같다. +-- +-- user 에 외래키를 걸지 않는다. mission_progress, user_cosmetic_loadout 과 같은 이유로, +-- INSERT 마다 부모 사용자 행에 공유 잠금이 붙으면 이미 사용자 행을 배타 잠금으로 잡는 +-- 미션 보상 지급 경로와 잠금 순서가 엇갈릴 수 있다. +-- +-- achievement_key 는 enum 이름(ARCHIVIST)이 아니라 API 와 앱 에셋이 쓰는 계약 키(archivist)를 담는다. +-- 행을 직접 들여다봤을 때 무슨 훈장인지 바로 알 수 있어야 한다. +-- +-- earned_at 은 애플리케이션이 KST 로 채워 넣는다. NOW(6) 을 쓰지 않는 이유는 DB 서버의 시간대가 +-- 앱과 다르면 받은 시각이 아홉 시간 어긋난 채로 앱에 뜨기 때문이다. +-- +-- 한 번 받은 훈장은 취소되지 않는다. 조건을 다시 계산했을 때 안 맞아도 행은 그대로 둔다. +-- 오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다. 그래서 이 테이블에는 DELETE 경로가 없다. +-- +-- V34 와 같은 이유로 IF NOT EXISTS 로 만들고 인덱스도 테이블 정의 안에 둔다. MySQL 에는 +-- CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 그 문장이 새로운 재실행 지점이 된다 +-- (V31 이 information_schema 가드를 쓴 이유가 그거다). 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. +CREATE TABLE IF NOT EXISTS user_achievement ( + user_id BIGINT NOT NULL, + achievement_key VARCHAR(32) NOT NULL, + earned_at DATETIME(6) NOT NULL, + PRIMARY KEY (user_id, achievement_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql b/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql new file mode 100644 index 00000000..29e6f3d7 --- /dev/null +++ b/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql @@ -0,0 +1,46 @@ +-- FCM 토큰 하나가 여러 사용자에게 묶인 행을 정리한다. (#271) +-- +-- 토큰은 기기 하나를 가리킨다. 그런데 등록이 (user_id, token) 쌍 단위라, 같은 기기에서 A 가 로그아웃하고 +-- B 가 로그인하면 (A, T) 와 (B, T) 가 함께 남았다. 그 뒤로 A 앞 알림(댓글 작성자 이름, 미리보기 포함)이 +-- B 가 쓰는 기기에 떴다. 등록 경로는 이번 변경에서 이전 소유자 행을 지우도록 고쳤고, +-- 이 파일은 그 전에 쌓인 행을 정리한다. +-- +-- 1) 탈퇴한 사용자의 토큰 행을 지운다. user 는 deleted_at 을 채우는 소프트 삭제라 행이 남아 있다. +-- 탈퇴 계정 행이 2) 에서 가장 최근 행으로 뽑히면 지금 그 기기를 쓰는 사용자의 행이 지워지므로 먼저 뺀다. +-- +-- 2) 토큰마다 가장 최근에 만들어진 행 하나만 남긴다. 순서는 created_at, 같으면 id 다. +-- - 등록은 이미 있는 쌍을 건드리지 않는다(FcmService.registerToken). 그래서 updated_at 은 created_at 과 같고, +-- "최근 행" 은 그 기기에 새 계정이 처음 등록된 시점이다. +-- - id 를 첫 기준으로 쓰지 않는다. FcmToken 은 테이블 기반 생성기(fcm_token_seq)로 id 를 미리 블록 단위로 +-- 받아 두기 때문에, 앱 인스턴스가 둘 이상이면 id 순서가 저장 순서와 어긋날 수 있다. +-- - 한계: 같은 기기에서 A → B → A 로 돌아온 경우 A 의 재등록은 새 행을 만들지 않아 B 행이 남는다. +-- 이 경우에도 새 코드에서는 그 기기에서 다음 로그인이나 토큰 갱신이 일어나는 순간 바로잡힌다. +-- - 행을 모두 지우는 쪽은 택하지 않았다. 앱은 로그인과 토큰 갱신 때만 토큰을 올리므로, +-- 지금 기기를 쓰는 사용자까지 다음 로그인 전까지 알림을 못 받게 된다. +-- +-- token 단독 유니크 제약은 걸지 않는다. Flyway 는 새 앱이 뜰 때 돌고 그동안 이전 버전 앱이 계속 등록을 받는다. +-- 정리와 제약 추가 사이에 중복 쌍이 한 번만 들어와도 ALTER 가 실패하고, MySQL DDL 은 롤백되지 않아 +-- 새 앱이 뜨지 못한 채 수동 복구가 필요해진다. 중복은 등록 경로에서 막고, 제약은 별도 작업으로 본다. +-- +-- 두 문장 모두 다시 돌려도 지울 행이 없어 안전하다. +-- 잠금: 두 DELETE 모두 fcm_token 전체를 한 번 읽으며, 읽는 행에 공유 잠금이 걸려 문장이 끝날 때까지 +-- 토큰 등록 INSERT 가 기다린다. 행 수에 비례해 짧게 끝나는 단일 문장이다. + +DELETE f +FROM fcm_token f +JOIN `user` u ON u.id = f.user_id +WHERE u.deleted_at IS NOT NULL; + +-- MySQL 은 DELETE 대상 테이블을 같은 문장의 서브쿼리에서 읽지 못한다(1093). +-- 윈도 함수가 들어간 파생 테이블은 병합되지 않고 임시 테이블로 먼저 만들어지므로 이 제약에 걸리지 않는다. +DELETE f +FROM fcm_token f +JOIN ( + SELECT ranked.id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY token ORDER BY created_at DESC, id DESC) AS row_num + FROM fcm_token + ) ranked + WHERE ranked.row_num > 1 +) stale ON stale.id = f.id; diff --git a/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql b/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql new file mode 100644 index 00000000..de8f6d88 --- /dev/null +++ b/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql @@ -0,0 +1,19 @@ +-- fcm_token.token 에 비유니크 보조 인덱스를 건다. +-- +-- 지금 이 테이블의 인덱스는 (user_id, token) 유니크 하나뿐이다. 선두 컬럼이 user_id 라 +-- token 단독 조회는 인덱스를 타지 못하고 테이블을 전부 읽는다. +-- 그 조회가 등록 경로 한복판에 있다. 등록은 로그인과 토큰 갱신 때마다 들어오고, +-- 매번 이전 소유자 행을 찾으려고 token 으로만 조회한다(FcmService -> FcmTokenWriter.register). +-- 중복 등록이 겹쳐 재시도까지 가면 같은 조회를 한 번 더 한다. +-- +-- 유니크가 아니라 보통 인덱스다. 한 토큰에 행이 둘 이상 남아 있어도 ALTER 가 실패하지 않는다. +-- (V43 주석에 적은 대로, 배포 중 이전 버전 앱이 중복 쌍을 넣으면 유니크 추가는 그대로 기동 실패가 된다.) +-- +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. MySQL 8.0 InnoDB 의 보조 인덱스 추가는 +-- 온라인 DDL 대상이라 진행 중에도 INSERT/DELETE 가 계속 처리된다. +-- 명시해 두면 어떤 이유로든 온라인으로 못 할 때 테이블을 오래 잠그는 대신 즉시 실패한다. +-- (실패하면 앱이 뜨지 않으므로 바로 알 수 있고, 잠금으로 서비스가 멈추는 쪽보다 낫다.) +ALTER TABLE fcm_token + ADD INDEX idx_fcm_token_token (token), + ALGORITHM = INPLACE, + LOCK = NONE; diff --git a/src/main/resources/db/migration/V45__create_quartz_tables.sql b/src/main/resources/db/migration/V45__create_quartz_tables.sql new file mode 100644 index 00000000..591e757f --- /dev/null +++ b/src/main/resources/db/migration/V45__create_quartz_tables.sql @@ -0,0 +1,167 @@ +-- QRTZ_ 스키마의 주인을 Quartz 초기화 스크립트에서 Flyway 로 옮긴다. +-- +-- 지금까지는 dev/prod 의 `spring.quartz.jdbc.initialize-schema: always` 가 기동할 때마다 +-- Quartz 기본 스크립트(tables_mysql_innodb.sql)를 실행했다. 그 스크립트는 DROP TABLE 11 줄로 +-- 시작해서, 배포할 때마다 사용자가 등록한 복습노트 알림 트리거와 챌린지 알림 트리거가 사라졌다. +-- +-- 이 마이그레이션은 전부 CREATE TABLE IF NOT EXISTS 다. 테이블이 이미 있는 dev/prod 에서는 +-- 아무 일도 하지 않고, QRTZ_ 테이블이 없는 환경에서만 만든다. 어느 쪽이든 기존 행은 건드리지 않는다. +-- +-- 정의는 quartz-2.3.2.jar 의 org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql 을 그대로 +-- 옮긴 것이다. 다만 CREATE INDEX 는 MySQL 에 IF NOT EXISTS 가 없어 재실행이 안전하지 않으므로, +-- 같은 이름/같은 컬럼의 인덱스를 CREATE TABLE 안으로 옮겼다. 결과 스키마는 동일하다. +-- 컬럼 타입도 운영에 이미 만들어져 있는 것과 맞추려고 원본 표기(BIGINT(13) 등)를 유지한다. + +CREATE TABLE IF NOT EXISTS QRTZ_JOB_DETAILS ( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR(190) NOT NULL, + JOB_GROUP VARCHAR(190) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + JOB_CLASS_NAME VARCHAR(250) NOT NULL, + IS_DURABLE VARCHAR(1) NOT NULL, + IS_NONCONCURRENT VARCHAR(1) NOT NULL, + IS_UPDATE_DATA VARCHAR(1) NOT NULL, + REQUESTS_RECOVERY VARCHAR(1) NOT NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_J_REQ_RECOVERY (SCHED_NAME, REQUESTS_RECOVERY), + INDEX IDX_QRTZ_J_GRP (SCHED_NAME, JOB_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + JOB_NAME VARCHAR(190) NOT NULL, + JOB_GROUP VARCHAR(190) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + NEXT_FIRE_TIME BIGINT(13) NULL, + PREV_FIRE_TIME BIGINT(13) NULL, + PRIORITY INTEGER NULL, + TRIGGER_STATE VARCHAR(16) NOT NULL, + TRIGGER_TYPE VARCHAR(8) NOT NULL, + START_TIME BIGINT(13) NOT NULL, + END_TIME BIGINT(13) NULL, + CALENDAR_NAME VARCHAR(190) NULL, + MISFIRE_INSTR SMALLINT(2) NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_T_J (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_T_JG (SCHED_NAME, JOB_GROUP), + INDEX IDX_QRTZ_T_C (SCHED_NAME, CALENDAR_NAME), + INDEX IDX_QRTZ_T_G (SCHED_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_T_STATE (SCHED_NAME, TRIGGER_STATE), + INDEX IDX_QRTZ_T_N_STATE (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE), + INDEX IDX_QRTZ_T_N_G_STATE (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE), + INDEX IDX_QRTZ_T_NEXT_FIRE_TIME (SCHED_NAME, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_ST (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_MISFIRE (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_ST_MISFIRE (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE), + INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE), + FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) + REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SIMPLE_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + REPEAT_COUNT BIGINT(7) NOT NULL, + REPEAT_INTERVAL BIGINT(12) NOT NULL, + TIMES_TRIGGERED BIGINT(10) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_CRON_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + CRON_EXPRESSION VARCHAR(120) NOT NULL, + TIME_ZONE_ID VARCHAR(80), + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SIMPROP_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13, 4) NULL, + DEC_PROP_2 NUMERIC(13, 4) NULL, + BOOL_PROP_1 VARCHAR(1) NULL, + BOOL_PROP_2 VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_BLOB_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + BLOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_CALENDARS ( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR(190) NOT NULL, + CALENDAR BLOB NOT NULL, + PRIMARY KEY (SCHED_NAME, CALENDAR_NAME) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_PAUSED_TRIGGER_GRPS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_FIRED_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR(95) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + INSTANCE_NAME VARCHAR(190) NOT NULL, + FIRED_TIME BIGINT(13) NOT NULL, + SCHED_TIME BIGINT(13) NOT NULL, + PRIORITY INTEGER NOT NULL, + STATE VARCHAR(16) NOT NULL, + JOB_NAME VARCHAR(190) NULL, + JOB_GROUP VARCHAR(190) NULL, + IS_NONCONCURRENT VARCHAR(1) NULL, + REQUESTS_RECOVERY VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME, ENTRY_ID), + INDEX IDX_QRTZ_FT_TRIG_INST_NAME (SCHED_NAME, INSTANCE_NAME), + INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY), + INDEX IDX_QRTZ_FT_J_G (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_FT_JG (SCHED_NAME, JOB_GROUP), + INDEX IDX_QRTZ_FT_T_G (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_FT_TG (SCHED_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SCHEDULER_STATE ( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR(190) NOT NULL, + LAST_CHECKIN_TIME BIGINT(13) NOT NULL, + CHECKIN_INTERVAL BIGINT(13) NOT NULL, + PRIMARY KEY (SCHED_NAME, INSTANCE_NAME) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_LOCKS ( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR(40) NOT NULL, + PRIMARY KEY (SCHED_NAME, LOCK_NAME) +) ENGINE = InnoDB; diff --git a/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql b/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql new file mode 100644 index 00000000..757bfbeb --- /dev/null +++ b/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql @@ -0,0 +1,29 @@ +-- 챌린지에 작성자를 남긴다. (#310) +-- +-- 챌린지는 멤버 전원이 만들 수 있는데 삭제는 방장만 할 수 있었다. 그래서 일반 멤버는 자기가 만든 +-- 챌린지를 스스로 지우지 못했다. 작성자 본인에게도 삭제를 열어 주려면 누가 만들었는지를 알아야 하는데, +-- study_room_challenge 에는 그 정보가 아예 없어 컬럼을 새로 만든다. +-- +-- study_room.host_user_id 와 같이 FK 없는 식별자로 둔다. user 는 소프트 삭제라 행이 남지만, +-- 같은 도메인 안에서 사람을 가리키는 방식을 하나로 맞추는 편이 읽기 쉽다. +-- +-- 기존 행의 작성자는 방장으로 채운다. 지금까지 이 챌린지들을 지울 수 있던 사람이 방장뿐이었으므로, +-- 방장으로 채우면 마이그레이션 전후로 누구도 권한을 새로 얻거나 잃지 않는다. +-- 실제 작성자가 일반 멤버였더라도 이 값으로는 삭제 권한이 늘어나지 않는다(방장은 원래 다 지울 수 있다). +-- +-- NULL 을 허용한 채 채운 뒤 NOT NULL 로 조인다. study_room_challenge.room_id 는 NOT NULL 이고 +-- study_room.host_user_id 도 NOT NULL 이라 조인으로 못 채우는 행은 남지 않는다. +-- +-- 잠금: 세 문장 모두 study_room_challenge 전체를 훑는다. 진행 중 챌린지가 방당 최대 5개인 테이블이라 +-- 행 수가 적어 짧게 끝난다. + +ALTER TABLE study_room_challenge + ADD COLUMN created_by_user_id BIGINT NULL AFTER room_id; + +UPDATE study_room_challenge c +JOIN study_room r ON r.id = c.room_id +SET c.created_by_user_id = r.host_user_id +WHERE c.created_by_user_id IS NULL; + +ALTER TABLE study_room_challenge + MODIFY COLUMN created_by_user_id BIGINT NOT NULL; diff --git a/src/main/resources/templates/admin-notice.html b/src/main/resources/templates/admin-notice.html new file mode 100644 index 00000000..70034e62 --- /dev/null +++ b/src/main/resources/templates/admin-notice.html @@ -0,0 +1,121 @@ + + + + + + OnO 관리자 - 공지 관리 + + + +

+ +
+
+

공지 관리

+

등록한 공지는 유저가 앱 메인에 들어올 때 팝업으로 보입니다. 동시에 한 건만 노출됩니다.

+
+ +
+
+ + +
+

현재 노출 중인 공지

+ +
+ 지금 노출 중인 공지가 없습니다. +
+ +
+
+
+
제목
+
+
+
+
내용
+
+
+
+
유형
+
+
+
+
노출 종료
+
+
+
+ +
+ +
+
+
+ + +
+

새 공지 등록

+

이미 노출 중인 공지가 있으면 자동으로 내려가고 새 공지로 바뀝니다.

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+ + diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 5e2b098d..96eef239 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -113,6 +113,24 @@

스터디룸 관리

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

공지 관리

+

앱 메인 팝업 공지 등록 및 제거

+
+
+
diff --git a/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java b/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java new file mode 100644 index 00000000..a7944d10 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.achievement.controller; + +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.user.entity.User; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +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; + +/** + * 프론트와 맞춘 응답 형태를 고정한다. 필드 이름이나 순서가 바뀌면 앱이 그대로 깨진다. + */ +@DisplayName("훈장 API 계약") +class AchievementControllerTest extends AchievementTestSupport { + + @Test + @DisplayName("GET /api/achievements - 열두 개가 훈장표 순서로 내려간다") + void returnsAllTwelveInOrder() throws Exception { + User user = fixtures.createUser(); + + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.achievements.length()").value(12)) + .andExpect(jsonPath("$.data.achievements[*].key").value(Matchers.contains( + "first_step", "archivist", "persistence", "phoenix", "dawn_class", "night_owl", + "perfect_month", "flawless", "organizer", "reviewer", "companion", "cheerleader"))) + .andExpect(jsonPath("$.data.achievements[0].nameKo").value("첫 걸음")) + .andExpect(jsonPath("$.data.achievements[0].descriptionKo").value("오답노트를 처음 적었어요")) + .andExpect(jsonPath("$.data.achievements[0].imageUrl").value("assets/Medal/first_step.png")) + .andExpect(jsonPath("$.data.achievements[0].earned").value(false)) + .andExpect(jsonPath("$.data.achievements[0].earnedAt").doesNotExist()) + .andExpect(jsonPath("$.data.newlyEarned").isArray()) + .andExpect(jsonPath("$.data.newlyEarned").value(Matchers.empty())); + } + + @Test + @DisplayName("GET /api/achievements - 받은 훈장은 earnedAt 이 차고 newlyEarned 에 실린다") + void earnedAchievementCarriesEarnedAt() throws Exception { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + String earned = "$.data.achievements[?(@.key == 'phoenix')]"; + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath(earned + ".earned").value(Matchers.contains(true))) + .andExpect(jsonPath(earned + ".earnedAt").value(Matchers.contains(Matchers.notNullValue()))) + .andExpect(jsonPath("$.data.newlyEarned") + .value(Matchers.containsInAnyOrder("first_step", "phoenix"))); + } + + @Test + @DisplayName("GET /api/achievements - 진행도가 있는 훈장은 current/target 이 차고, 없는 훈장은 null 이다") + void progressFields() throws Exception { + User user = fixtures.createUser(); + saveFolders(user.getId(), 4); + + String organizer = "$.data.achievements[?(@.key == 'organizer')]"; + String phoenix = "$.data.achievements[?(@.key == 'phoenix')]"; + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath(organizer + ".current").value(Matchers.contains(4))) + .andExpect(jsonPath(organizer + ".target").value(Matchers.contains(10))) + .andExpect(jsonPath(phoenix + ".current").value(Matchers.contains(Matchers.nullValue()))) + .andExpect(jsonPath(phoenix + ".target").value(Matchers.contains(Matchers.nullValue()))); + } + + @Test + @DisplayName("GET /api/achievements - 남의 훈장은 안 보인다") + void doesNotLeakOtherUsersAchievements() throws Exception { + User owner = fixtures.createUser(); + User other = fixtures.createOtherUser(); + saveProblems(owner.getId(), 1); + achievementService.getAchievements(owner.getId()); + + mockMvc.perform(get("/api/achievements").with(asUser(other.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.achievements[?(@.earned == true)]").value(Matchers.empty())) + .andExpect(jsonPath("$.data.newlyEarned").value(Matchers.empty())); + } + + @Test + @DisplayName("GET /api/achievements - 비로그인은 401") + void requiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(get("/api/achievements")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java new file mode 100644 index 00000000..c6ec4119 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java @@ -0,0 +1,177 @@ +package com.aisip.OnO.backend.achievement.migration; + +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지, 그리고 엔티티와 스키마가 어긋나지 않는지. + * + *

테스트는 Flyway 를 끄고 Hibernate 가 만든 스키마를 쓴다({@code application-test.yml}). + * 그래서 V42 가 실제로 적용 가능한지는 dev 서버 기동 전에는 드러나지 않는다. 최소한 재실행 + * 안전성과 엔티티-스키마 일치만이라도 여기서 잠근다. {@code CosmeticMigrationTest} 와 같은 방식이다. + */ +@DisplayName("훈장 마이그레이션") +class AchievementMigrationTest extends AchievementTestSupport { + + private static final String MIGRATION = "db/migration/V42__create_user_achievement.sql"; + + @Test + @DisplayName("DDL 은 다시 돌려도 안전하게 쓰여 있다") + void ddlIsRerunnable() { + String ddl = statements(); + + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS USER_ACHIEVEMENT"); + // MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없다. 인덱스를 따로 만들면 그 문장이 재실행 지점이 된다. + assertThat(ddl).doesNotContain("CREATE INDEX"); + // 훈장 목록은 코드의 enum 이라 시드가 없다. 시드가 섞이면 그 자리에서 끊길 때 테이블만 남는다. + assertThat(ddl).doesNotContain("INSERT INTO"); + } + + @Test + @DisplayName("이미 테이블이 있는 스키마에 다시 돌려도 터지지 않는다") + void rerunningDoesNotFail() { + // 테스트 DB 에는 user_achievement 가 이미 있다(엔티티에서 Hibernate 가 만든다). 그 위에 두 번 더 돌린다. + for (int attempt = 0; attempt < 2; attempt++) { + runMigrationOnOneConnection(); + } + + assertThat(achievementRowCount(fixtures.createUser().getId())).isZero(); + } + + /** + * 멱등성의 근거가 두 컬럼 복합 기본키라는 것을 잠근다. + * + *

컬럼 순서까지는 단언하지 않는다. 운영 스키마는 이 마이그레이션이 만들어 + * {@code (user_id, achievement_key)} 인데, 테스트 스키마는 Hibernate 가 {@code @IdClass} 의 + * 속성 이름 순으로 만들어 {@code (achievement_key, user_id)} 가 된다. + * {@code user_cosmetic_loadout} 도 같은 사정이다. 중복을 막는 성질은 순서와 무관하므로 + * 여기서는 두 컬럼이 함께 기본키라는 것만 본다. 순서는 마이그레이션 본문으로 확인한다. + */ + @Test + @DisplayName("기본키가 (user_id, achievement_key) 복합키다 - 멱등성의 근거다") + void primaryKeyIsComposite() { + List keyColumns = jdbcTemplate.queryForList(""" + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_achievement' + AND CONSTRAINT_NAME = 'PRIMARY' + """, String.class); + + assertThat(keyColumns).containsExactlyInAnyOrder("user_id", "achievement_key"); + assertThat(statements()) + .as("운영 스키마는 user_id 가 선두여야 한다. 조회가 언제나 user_id 로 들어온다") + .contains("PRIMARY KEY (USER_ID, ACHIEVEMENT_KEY)"); + } + + @Test + @DisplayName("achievement_key 길이가 마이그레이션과 엔티티에서 같다") + void keyColumnLengthMatchesMigration() { + Long length = jdbcTemplate.queryForObject(""" + SELECT CHARACTER_MAXIMUM_LENGTH + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_achievement' + AND COLUMN_NAME = 'achievement_key' + """, Long.class); + + assertThat(statements()).contains("ACHIEVEMENT_KEY VARCHAR(32)"); + assertThat(length) + .as("엔티티의 length 와 마이그레이션이 갈리면 길이 초과 오류를 테스트가 재현하지 못한다") + .isEqualTo(32L); + } + + /** + * 응원단장이 세는 리액션 세 테이블이 {@code user_id} 로 시작하는 인덱스를 갖고 있는지. + * + *

훈장 화면을 열 때마다 세 테이블에 {@code COUNT(*) WHERE user_id = ?} 가 나간다. 인덱스가 + * 없으면 그대로 풀스캔이고, 리액션은 사용자가 늘수록 가장 빨리 자라는 표 중 하나다. + * + *

지금은 인덱스를 새로 만들 필요가 없다. 세 테이블 모두 {@code user_id} 에 사용자 테이블을 향한 + * 외래키가 걸려 있어 InnoDB 가 {@code (user_id)} 단독 인덱스를 함께 만들어 두었고(V6, V11), + * 댓글 리액션은 {@code idx_shared_problem_comment_reaction_user} 로 명시까지 돼 있다. + * 같은 것을 한 벌 더 만들면 쓰기마다 갱신할 인덱스만 늘어난다. + * + *

그래서 이 테스트가 필요하다. 근거가 외래키가 딸려 만든 인덱스라, 나중에 외래키를 떼는 + * 변경이 있으면 인덱스도 조용히 같이 사라진다. 그때 훈장 화면이 느려지고 나서야 알게 되는 대신 + * 여기서 걸린다. + */ + @Test + @DisplayName("리액션 세 테이블이 user_id 선두 인덱스를 갖고 있다 - 응원단장 카운트가 이것을 탄다") + void reactionTablesAreIndexedByUserId() { + List reactionTables = List.of( + "study_room_feed_reaction", + "study_room_shared_problem_reaction", + "study_room_shared_problem_comment_reaction"); + + for (String table : reactionTables) { + Long indexCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = 'user_id' + AND SEQ_IN_INDEX = 1 + """, Long.class, table); + + assertThat(indexCount) + .as(table + " 에 user_id 선두 인덱스가 없으면 훈장 화면을 열 때마다 풀스캔이 돈다") + .isNotNull() + .isPositive(); + } + } + + private void runMigrationOnOneConnection() { + List statements = executableStatements(); + jdbcTemplate.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } + return null; + }); + } + + private List executableStatements() { + return Arrays.stream(withoutComments().split(";")) + .map(String::trim) + .filter(statement -> !statement.isEmpty()) + .toList(); + } + + /** 주석을 걷어내고 대문자로 맞춘 본문. 공백 차이에 걸리지 않게 여러 공백은 하나로 줄인다. */ + private String statements() { + return withoutComments().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private String withoutComments() { + return Arrays.stream(read().split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + } + + private String read() { + try (InputStream inputStream = new ClassPathResource(MIGRATION).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java new file mode 100644 index 00000000..0543cd2c --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java @@ -0,0 +1,528 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.dto.AchievementResponseDto; +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("훈장 서비스") +class AchievementServiceTest extends AchievementTestSupport { + + /** 훈장표의 순서 그대로다. 앱이 이 순서를 그대로 그린다. */ + private static final List ORDERED_KEYS = List.of( + "first_step", "archivist", "persistence", "phoenix", "dawn_class", "night_owl", + "perfect_month", "flawless", "organizer", "reviewer", "companion", "cheerleader"); + + @Nested + @DisplayName("목록") + class Catalog { + + @Test + @DisplayName("아무것도 안 한 사용자에게도 열두 개가 전부 내려가고, 순서는 훈장표 순서다") + void returnsAllTwelveInContractOrder() { + User user = fixtures.createUser(); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(response.achievements()).hasSize(12); + assertThat(response.achievements().stream().map(AchievementResponseDto::key)) + .as("서버가 정한 순서를 앱이 그대로 그린다. 앱은 다시 정렬하지 않는다") + .containsExactlyElementsOf(ORDERED_KEYS); + assertThat(response.achievements()).allMatch(item -> !item.earned()); + assertThat(response.newlyEarned()).isEmpty(); + } + + @Test + @DisplayName("enum 순서와 훈장표 순서가 어긋나지 않는다") + void enumOrderMatchesContract() { + assertThat(java.util.Arrays.stream(Achievement.values()).map(Achievement::getKey)) + .containsExactlyElementsOf(ORDERED_KEYS); + } + + @Test + @DisplayName("이미지 경로는 앱 번들 경로다") + void imageUrlIsBundlePath() { + User user = fixtures.createUser(); + + assertThat(itemOf(user, "archivist").imageUrl()).isEqualTo("assets/Medal/archivist.png"); + } + } + + @Nested + @DisplayName("판정과 적립") + class Earning { + + @Test + @DisplayName("조건을 채우면 받고 newlyEarned 에 실린다") + void earnsWhenConditionMet() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(response.newlyEarned()).containsExactly("first_step"); + assertThat(itemOf(response, "first_step").earned()).isTrue(); + assertThat(itemOf(response, "first_step").earnedAt()).isNotNull(); + assertThat(achievementRowCount(user.getId())).isEqualTo(1); + } + + @Test + @DisplayName("연달아 두 번 부르면 두 번째는 newlyEarned 가 비고 행도 안 늘어난다") + void isIdempotent() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto first = achievementService.getAchievements(user.getId()); + AchievementListResponseDto second = achievementService.getAchievements(user.getId()); + + assertThat(first.newlyEarned()).containsExactly("first_step"); + assertThat(second.newlyEarned()).isEmpty(); + assertThat(achievementRowCount(user.getId())).isEqualTo(1); + assertThat(itemOf(second, "first_step").earned()).isTrue(); + } + + @Test + @DisplayName("받은 날짜는 두 번째 호출에서 덮어쓰이지 않는다") + void keepsFirstEarnedAt() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + LocalDateTime earnedAt = itemOf(achievementService.getAchievements(user.getId()), "first_step").earnedAt(); + jdbcTemplate.update("UPDATE user_achievement SET earned_at = ? WHERE user_id = ?", + LocalDateTime.of(2020, 1, 1, 0, 0), user.getId()); + + assertThat(earnedAt).isNotNull(); + assertThat(itemOf(achievementService.getAchievements(user.getId()), "first_step").earnedAt()) + .as("이미 있는 행은 건드리지 않는다. 덮어쓰면 화면을 열 때마다 받은 날이 오늘로 바뀐다") + .isEqualTo(LocalDateTime.of(2020, 1, 1, 0, 0)); + } + + @Test + @DisplayName("받은 뒤 데이터를 지워도 훈장은 남는다") + void neverRevoked() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + achievementService.getAchievements(user.getId()); + + problemRepository.delete(problem); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + assertThat(itemOf(response, "first_step").earned()) + .as("오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다") + .isTrue(); + assertThat(response.newlyEarned()).isEmpty(); + } + + @Test + @DisplayName("기록광은 오답노트 백 개에서 열린다 - 경계는 포함이다") + void archivistBoundaryIsInclusive() { + User user = fixtures.createUser(); + saveProblems(user.getId(), 99); + assertThat(itemOf(user, "archivist").earned()).isFalse(); + + saveProblem(user.getId()); + + assertThat(itemOf(user, "archivist").earned()).isTrue(); + } + + @Test + @DisplayName("남의 데이터는 내 훈장에 안 잡힌다") + void countsOnlyOwnData() { + User user = fixtures.createUser(); + User other = fixtures.createOtherUser(); + saveProblems(other.getId(), 3); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "first_step").earned()).isFalse(); + assertThat(itemOf(response, "archivist").current()).isZero(); + } + } + + @Nested + @DisplayName("진행도") + class Progress { + + @Test + @DisplayName("잠긴 훈장도 지금 몇까지 왔는지 함께 내려준다") + void showsCurrentAndTarget() { + User user = fixtures.createUser(); + saveFolders(user.getId(), 4); + + AchievementResponseDto organizer = itemOf(user, "organizer"); + + assertThat(organizer.earned()).isFalse(); + assertThat(organizer.current()).isEqualTo(4); + assertThat(organizer.target()).isEqualTo(10); + } + + @Test + @DisplayName("가입 직후 기본 폴더만 있는 사용자는 정리의 신이 1 에서 시작한다") + void defaultFoldersDoNotInflateProgress() { + User user = fixtures.createUser(); + // 실제 가입 경로가 부르는 것과 같은 메서드다. 루트 폴더와 기본 하위 폴더가 생긴다. + folderService.initializeDefaultFoldersIfAbsent(user.getId()); + + assertThat(itemOf(user, "organizer").current()) + .as("아무것도 안 한 사람이 2/10 에서 시작하면 '폴더를 열 개나 만들어 정리했어요' 와 안 맞는다") + .isLessThanOrEqualTo(1L); + } + + @Test + @DisplayName("루트 폴더는 안 센다") + void rootFolderIsNotCounted() { + User user = fixtures.createUser(); + fixtures.createRootFolder(user.getId()); + + assertThat(itemOf(user, "organizer").current()) + .as("루트는 사용자가 만든 것이 아니라 parentFolder IS NULL 이라는 구조로 걸러진다") + .isZero(); + } + + @Test + @DisplayName("목표치를 넘어도 목표치로 잘라서 준다") + void clampsToTarget() { + User user = fixtures.createUser(); + saveFolders(user.getId(), 13); + + AchievementResponseDto organizer = itemOf(user, "organizer"); + + assertThat(organizer.earned()).isTrue(); + assertThat(organizer.current()).isEqualTo(10); + assertThat(organizer.target()).isEqualTo(10); + } + + @Test + @DisplayName("불사조와 첫 걸음은 진행도가 null 이다") + void noProgressForBinaryAchievements() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "first_step").current()).isNull(); + assertThat(itemOf(response, "first_step").target()).isNull(); + assertThat(itemOf(response, "phoenix").current()).isNull(); + assertThat(itemOf(response, "phoenix").target()).isNull(); + } + } + + @Nested + @DisplayName("새벽반 · 올빼미") + class TimeOfDay { + + @ParameterizedTest(name = "{0}시 {1}분 복습은 새벽반 {2} / 올빼미 {3}") + @CsvSource({ + "0, 0, 0, 1", + "2, 59, 0, 1", + "3, 0, 0, 0", + "4, 59, 0, 0", + "5, 0, 1, 0", + "7, 59, 1, 0", + "8, 0, 0, 0", + "23, 59, 0, 0", + }) + @DisplayName("경계는 KST 시각으로 05:00~08:00 과 00:00~03:00 이다") + void countsByKstHour(int hour, int minute, long dawn, long night) { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolve(problem, user.getId(), LocalDateTime.of(2026, 3, 2, hour, minute), AnswerStatus.CORRECT); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "dawn_class").current()).isEqualTo(dawn); + assertThat(itemOf(response, "night_owl").current()).isEqualTo(night); + } + + @Test + @DisplayName("새벽 열 번이면 받는다") + void earnsDawnClassAtTen() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + for (int i = 0; i < 10; i++) { + saveSolve(problem, user.getId(), + LocalDateTime.of(2026, 3, 2, 6, 0).plusDays(i), AnswerStatus.CORRECT); + } + + assertThat(itemOf(user, "dawn_class").earned()).isTrue(); + } + } + + @Nested + @DisplayName("무결점") + class Flawless { + + @Test + @DisplayName("UNKNOWN 에서 끊기지 않는다") + void unknownDoesNotBreakStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.UNKNOWN, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + assertThat(itemOf(user, "flawless").earned()) + .as("UNKNOWN 은 그때 맞혔는지 모르는 것이라 틀렸다고 보면 없는 실패를 만드는 셈이다") + .isTrue(); + } + + @Test + @DisplayName("오답에서 끊긴다") + void wrongBreaksStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.WRONG, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + AchievementResponseDto flawless = itemOf(user, "flawless"); + assertThat(flawless.earned()).isFalse(); + assertThat(flawless.current()).isEqualTo(5); + } + + @Test + @DisplayName("부분 정답에서도 끊긴다") + void partialBreaksStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.PARTIAL, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + assertThat(itemOf(user, "flawless").earned()).isFalse(); + } + } + + @Nested + @DisplayName("불사조") + class Phoenix { + + @Test + @DisplayName("오답 뒤에 정답이면 받는다") + void wrongThenCorrect() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + assertThat(itemOf(user, "phoenix").earned()).isTrue(); + } + + @Test + @DisplayName("정답 뒤 오답 순서에서는 안 걸린다") + void correctThenWrongDoesNotCount() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.CORRECT, AnswerStatus.WRONG); + + assertThat(itemOf(user, "phoenix").earned()) + .as("practicedAt 순서를 본다. 틀린 뒤에 맞힌 것만 불사조다") + .isFalse(); + } + + @Test + @DisplayName("부분 정답은 맞힌 것으로 치지 않는다") + void partialIsNotCorrect() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.PARTIAL); + + assertThat(itemOf(user, "phoenix").earned()).isFalse(); + } + + @Test + @DisplayName("다른 문제에서 맞힌 것은 안 친다") + void differentProblemDoesNotCount() { + User user = fixtures.createUser(); + saveSolve(saveProblem(user.getId()), user.getId(), NOON, AnswerStatus.WRONG); + saveSolve(saveProblem(user.getId()), user.getId(), NOON.plusHours(1), AnswerStatus.CORRECT); + + assertThat(itemOf(user, "phoenix").earned()).isFalse(); + } + } + + @Nested + @DisplayName("집념") + class Persistence { + + @Test + @DisplayName("한 문제를 다섯 번 보면 받는다") + void sameProblemFiveTimes() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + assertThat(itemOf(user, "persistence").earned()).isTrue(); + } + + @Test + @DisplayName("다섯 문제를 한 번씩 본 것은 다르다") + void fiveProblemsOnceEachDoesNotCount() { + User user = fixtures.createUser(); + for (int i = 0; i < 5; i++) { + saveSolve(saveProblem(user.getId()), user.getId(), NOON.plusHours(i), AnswerStatus.WRONG); + } + + AchievementResponseDto persistence = itemOf(user, "persistence"); + assertThat(persistence.earned()).isFalse(); + assertThat(persistence.current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("개근") + class PerfectMonth { + + @Test + @DisplayName("연속 서른 날이면 받는다") + void thirtyConsecutiveDays() { + User user = fixtures.createUser(); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 30); + + assertThat(itemOf(user, "perfect_month").earned()).isTrue(); + } + + @Test + @DisplayName("스물아홉 날에서 하루 빠지면 안 받는다") + void gapBreaksStreak() { + User user = fixtures.createUser(); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 29); + saveLoginDays(user, LocalDate.of(2026, 2, 1), 20); + + AchievementResponseDto perfectMonth = itemOf(user, "perfect_month"); + assertThat(perfectMonth.earned()).isFalse(); + assertThat(perfectMonth.current()) + .as("가장 길었던 구간을 본다. 지금 이어지는 20 일이 아니라 지난달의 29 일이다") + .isEqualTo(29); + } + + @Test + @DisplayName("하루에 여러 번 로그인해도 하루로 센다") + void multipleLoginsInOneDayCountOnce() { + User user = fixtures.createUser(); + for (int i = 0; i < 30; i++) { + saveLoginAt(user, LocalDate.of(2026, 1, 1).atTime(9, 0)); + } + + assertThat(itemOf(user, "perfect_month").current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("회고왕") + class Reviewer { + + @Test + @DisplayName("공백만 적은 회고는 안 센다") + void blankReflectionDoesNotCount() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolve(problem, user.getId(), NOON, AnswerStatus.CORRECT, "적었다"); + saveSolve(problem, user.getId(), NOON.plusHours(1), AnswerStatus.CORRECT, " "); + saveSolve(problem, user.getId(), NOON.plusHours(2), AnswerStatus.CORRECT, null); + + assertThat(itemOf(user, "reviewer").current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("동행 · 응원단장") + class StudyRoomAchievements { + + @Test + @DisplayName("스터디룸 한 곳에 들어가면 동행을 받는다") + void joiningOneRoomEarnsCompanion() { + User user = fixtures.createUser(); + joinNewRoom(user); + + assertThat(itemOf(user, "companion").earned()).isTrue(); + } + + @Test + @DisplayName("응원은 리액션 세 자리를 합쳐 센다") + void reactionsAreSummedAcrossThreeTables() { + User user = fixtures.createUser(); + StudyRoom room = joinNewRoom(user); + saveFeedReactions(user, room, 40); + saveSharedProblemReactions(user, room, 40); + saveCommentReactions(user, room, 20); + + assertThat(itemOf(user, "cheerleader").earned()) + .as("누른 자리가 어디든 응원한 것은 응원한 것이다") + .isTrue(); + } + + @Test + @DisplayName("합쳐도 백에 못 미치면 진행도만 오른다") + void reactionsBelowThreshold() { + User user = fixtures.createUser(); + StudyRoom room = joinNewRoom(user); + saveFeedReactions(user, room, 3); + saveCommentReactions(user, room, 2); + + AchievementResponseDto cheerleader = itemOf(user, "cheerleader"); + assertThat(cheerleader.earned()).isFalse(); + assertThat(cheerleader.current()).isEqualTo(5); + } + } + + @Nested + @DisplayName("쿼리 수") + class QueryCount { + + /** + * 훈장 화면을 열 때마다 도는 경로다. 열두 조건을 각각 세면 열둘이 넘는데, 여섯이 같은 + * 복습 기록 표를 보므로 그 표를 한 번만 읽는다. 이 숫자가 늘면 조건을 하나씩 세기 시작한 것이다. + */ + @Test + @DisplayName("조회 한 번에 아홉 번 나간다") + void countsNineQueries() { + User user = fixtures.createUser(); + saveProblems(user.getId(), 2); + saveFolders(user.getId(), 2); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 2); + // 새로 받을 훈장이 없는 정상 상태를 잰다. 첫 호출은 INSERT 가 섞여 숫자가 달라진다. + achievementService.getAchievements(user.getId()); + + long queryCount = queryCounter.count(() -> achievementService.getAchievements(user.getId())); + + assertThat(queryCount).isEqualTo(9); + } + } + + // ─────────────────────────── 도우미 ─────────────────────────── + + private AchievementResponseDto itemOf(User user, String key) { + return itemOf(achievementService.getAchievements(user.getId()), key); + } + + private AchievementResponseDto itemOf(AchievementListResponseDto response, String key) { + return response.achievements().stream() + .filter(item -> item.key().equals(key)) + .findFirst() + .orElseThrow(() -> new AssertionError("훈장 " + key + " 가 응답에 없다")); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java new file mode 100644 index 00000000..2591be6a --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java @@ -0,0 +1,251 @@ +package com.aisip.OnO.backend.achievement.support; + +import com.aisip.OnO.backend.achievement.repository.UserAchievementRepository; +import com.aisip.OnO.backend.achievement.service.AchievementService; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemComment; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemCommentReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemReaction; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.support.QueryCounter; +import com.aisip.OnO.backend.user.entity.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; + +/** + * 훈장 도메인 테스트의 공통 베이스. + * + *

{@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + * + *

훈장은 열두 조건이 여섯 테이블에 흩어져 있어, 조건 하나를 확인하려면 그 테이블에 데이터를 + * 직접 심어야 한다. 서비스 경로로 만들면 미션 적립이 함께 끌려 들어와 무엇을 재는 테스트인지 흐려진다. + */ +public abstract class AchievementTestSupport extends IntegrationTestSupport { + + private static final AtomicLong SEQUENCE = new AtomicLong(); + + /** 복습 시각의 기본값. 새벽반(05~08)과 올빼미(00~03) 어느 쪽에도 안 걸리는 시각이다. */ + protected static final LocalDateTime NOON = LocalDateTime.of(2026, 3, 2, 12, 0); + + @Autowired + protected AchievementService achievementService; + + @Autowired + protected UserAchievementRepository userAchievementRepository; + + @Autowired + protected ProblemRepository problemRepository; + + @Autowired + protected FolderService folderService; + + @Autowired + protected ProblemSolveRepository problemSolveRepository; + + @Autowired + protected MissionLogRepository missionLogRepository; + + @Autowired + protected StudyRoomRepository studyRoomRepository; + + @Autowired + protected StudyRoomFeedRepository feedRepository; + + @Autowired + protected StudyRoomFeedReactionRepository feedReactionRepository; + + @Autowired + protected StudyRoomSharedProblemRepository sharedProblemRepository; + + @Autowired + protected StudyRoomSharedProblemReactionRepository sharedProblemReactionRepository; + + @Autowired + protected StudyRoomSharedProblemCommentRepository commentRepository; + + @Autowired + protected StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + + @Autowired + protected QueryCounter queryCounter; + + @Autowired + protected JdbcTemplate jdbcTemplate; + + /** + * MockMvc 요청에 인증 주체를 싣는다. + * + *

{@code authenticateAs(userId)} 만으로도 MockMvc 가 인증을 읽지만, 한 테스트에서 + * 사용자를 바꿔 가며 요청할 때는 요청 단위로 붙이는 편이 어느 사용자의 요청인지 분명하다. + */ + protected RequestPostProcessor asUser(Long userId) { + return authentication(new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + } + + // ─────────────────────────── 오답노트 · 폴더 ─────────────────────────── + + protected Problem saveProblem(Long userId) { + return problemRepository.save( + Problem.from(new ProblemRegisterDto(null, "메모", null, null, null), userId)); + } + + protected void saveProblems(Long userId, int count) { + for (int i = 0; i < count; i++) { + saveProblem(userId); + } + } + + /** + * 루트 폴더 하나와 그 아래 하위 폴더 {@code count} 개. + * + *

훈장은 루트를 안 센다. 사용자가 직접 만드는 폴더는 언제나 루트 아래에 붙으므로 + * ({@code FolderService.createFolder} 는 부모 없는 생성을 거절한다) 같은 모양으로 만든다. + * 전부 루트로 만들면 판정이 0 이 나와 무엇을 재는 테스트인지 알 수 없게 된다. + */ + protected void saveFolders(Long userId, int count) { + Folder root = fixtures.createRootFolder(userId); + for (int i = 0; i < count; i++) { + fixtures.createFolder(userId, "폴더" + SEQUENCE.incrementAndGet(), root); + } + } + + // ─────────────────────────── 복습 기록 ─────────────────────────── + + protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime practicedAt, AnswerStatus status) { + return saveSolve(problem, userId, practicedAt, status, "회고"); + } + + protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime practicedAt, + AnswerStatus status, String reflection) { + return problemSolveRepository.save( + ProblemSolve.create(problem, userId, practicedAt, status, reflection, null, 120, null)); + } + + /** + * 같은 문제를 시각만 한 시간씩 밀어 여러 번 푼 기록. + * + *

무결점과 불사조는 복습 시각 순서를 보기 때문에 모든 기록을 같은 시각에 넣으면 + * 무엇을 재는 테스트인지 알 수 없게 된다. 항상 순서가 드러나게 만든다. + */ + protected void saveSolveSequence(Problem problem, Long userId, LocalDateTime start, AnswerStatus... statuses) { + for (int i = 0; i < statuses.length; i++) { + saveSolve(problem, userId, start.plusHours(i), statuses[i]); + } + } + + // ─────────────────────────── 로그인 기록 ─────────────────────────── + + /** + * {@code date} 부터 하루씩 이어지는 로그인 기록. + * + *

{@code created_at} 은 JPA Auditing 이 채우므로 애플리케이션에서는 과거 시각을 만들 수 없다. + * 연속 출석은 과거 날짜 없이는 검증할 수 없어 직접 밀어 넣는다. {@code MissionTestSupport} 와 같은 방법이다. + */ + protected void saveLoginDays(User user, LocalDate startDate, int days) { + for (int i = 0; i < days; i++) { + saveLoginAt(user, startDate.plusDays(i).atTime(9, 0)); + } + } + + protected void saveLoginAt(User user, LocalDateTime createdAt) { + MissionLog log = missionLogRepository.save(MissionLog.from( + MissionRegisterDto.builder() + .userId(user.getId()) + .missionType(MissionType.USER_LOGIN) + .referenceId(SEQUENCE.incrementAndGet()) + .build(), + user)); + jdbcTemplate.update("UPDATE mission_log SET created_at = ? WHERE id = ?", createdAt, log.getId()); + } + + // ─────────────────────────── 스터디룸 · 리액션 ─────────────────────────── + + protected StudyRoom joinNewRoom(User user) { + StudyRoom room = StudyRoom.create("스터디룸" + SEQUENCE.incrementAndGet(), user.getId()); + room.addMember(StudyRoomMember.create(user, StudyRoomMemberRole.HOST)); + return studyRoomRepository.save(room); + } + + /** + * 피드 하나에 리액션 {@code count} 개. + * + *

이모지를 매번 다르게 쓴다. {@code (feed_id, user_id, emoji)} 유니크 키 때문에 같은 이모지로는 + * 한 번만 누를 수 있다. 응원 백 번을 만들려고 피드를 백 개 만드는 것보다 이쪽이 싸다. + */ + protected void saveFeedReactions(User user, StudyRoom room, int count) { + StudyRoomFeed feed = feedRepository.save( + StudyRoomFeed.create(room, user, StudyRoomFeedEventType.PRACTICE_COMPLETED, null)); + for (int i = 0; i < count; i++) { + feedReactionRepository.save(StudyRoomFeedReaction.create(feed, user, emoji())); + } + } + + protected void saveSharedProblemReactions(User user, StudyRoom room, int count) { + StudyRoomSharedProblem shared = sharedProblemRepository.save( + StudyRoomSharedProblem.create(room, user, saveProblem(user.getId()), "같이 봐요")); + for (int i = 0; i < count; i++) { + sharedProblemReactionRepository.save( + StudyRoomSharedProblemReaction.create(shared, user, emoji())); + } + } + + protected void saveCommentReactions(User user, StudyRoom room, int count) { + StudyRoomSharedProblem shared = sharedProblemRepository.save( + StudyRoomSharedProblem.create(room, user, saveProblem(user.getId()), "같이 봐요")); + StudyRoomSharedProblemComment comment = commentRepository.save( + StudyRoomSharedProblemComment.create(shared, user, "저도 틀렸어요")); + for (int i = 0; i < count; i++) { + commentReactionRepository.save( + StudyRoomSharedProblemCommentReaction.create(comment, user, emoji())); + } + } + + private String emoji() { + return "cheer_" + SEQUENCE.incrementAndGet(); + } + + // ─────────────────────────── 확인 ─────────────────────────── + + /** 훈장 행 개수. 두 번 불러도 안 늘어나는지 확인할 때 쓴다. */ + protected long achievementRowCount(Long userId) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_achievement WHERE user_id = ?", Long.class, userId); + return count == null ? 0 : count; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisControllerTest.java new file mode 100644 index 00000000..cf000781 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminAnalysisControllerTest.java @@ -0,0 +1,263 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.user.dto.UserResponseDto; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +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.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminAnalysisController") +class AdminAnalysisControllerTest extends AdminTestSupport { + + private static final LocalDate TODAY = LocalDate.now(); + + @BeforeEach + void loginAsAdmin() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + } + + @SuppressWarnings("unchecked") + private Map dailyMap(MvcResult result, String name) { + return (Map) result.getModelAndView().getModel().get(name); + } + + @Nested + @DisplayName("종합 통계") + class Overview { + + @Test + @DisplayName("집계 대상 데이터가 없어도 500이 아니라 0으로 채운 화면을 준다") + void rendersZeroesWhenNoDataExists() throws Exception { + MvcResult result = mockMvc.perform(get("/admin/analysis")) + .andExpect(status().isOk()) + .andExpect(view().name("analysis")) + .andExpect(model().attribute("allProblemCount", 0L)) + .andExpect(model().attribute("allPracticeNoteCount", 0L)) + .andExpect(model().attribute("allPracticeLogCount", 0L)) + .andExpect(model().attribute("allProblemAnalysisCount", 0L)) + .andExpect(model().attribute("periodVisitCount", 0L)) + .andExpect(model().attribute("periodActiveUserCount", 0L)) + .andExpect(model().attribute("periodUniqueVisitorCount", 0L)) + .andExpect(model().attribute("periodPracticeNoteCount", 0L)) + .andExpect(model().attribute("periodPracticeLogCount", 0L)) + .andExpect(model().attribute("periodProblemCount", 0L)) + .andExpect(model().attribute("periodAnalysisFailureRate", 0.0)) + .andExpect(model().attribute("averageDailyVisitors", 0.0)) + .andReturn(); + + assertThat(dailyMap(result, "dailyActiveUsers").values()) + .as("데이터가 없는 날짜도 비워두지 말고 0으로 채워야 그래프가 끊기지 않는다") + .containsOnly(0L); + } + + @Test + @DisplayName("기간을 지정하지 않으면 최근 30일을 본다") + void defaultsToLastThirtyDays() throws Exception { + mockMvc.perform(get("/admin/analysis")) + .andExpect(status().isOk()) + .andExpect(model().attribute("startDate", TODAY.minusDays(29))) + .andExpect(model().attribute("endDate", TODAY)) + .andExpect(model().attribute("quickStart7Days", TODAY.minusDays(6))) + .andExpect(model().attribute("quickStart30Days", TODAY.minusDays(29))) + .andExpect(model().attribute("quickStart90Days", TODAY.minusDays(89))); + } + + @Test + @DisplayName("로그인 미션이 쌓이면 방문 수와 순 방문자 수로 집계된다") + void aggregatesLoginMissionsAsVisits() throws Exception { + User first = fixtures.createUser(); + User second = fixtures.createUser(); + saveMissionLog(first, MissionType.USER_LOGIN, null); + saveMissionLog(second, MissionType.USER_LOGIN, null); + + MvcResult result = mockMvc.perform(get("/admin/analysis") + .param("startDate", TODAY.toString()) + .param("endDate", TODAY.toString())) + .andExpect(status().isOk()) + .andExpect(model().attribute("periodVisitCount", 2L)) + .andExpect(model().attribute("periodUniqueVisitorCount", 2L)) + .andExpect(model().attribute("averageDailyVisitors", 2.0)) + .andReturn(); + + assertThat(dailyMap(result, "dailyVisits").get(TODAY)).isEqualTo(2L); + assertThat(dailyMap(result, "dailyActiveUsers").get(TODAY)).isEqualTo(2L); + } + + @Test + @DisplayName("같은 사용자가 여러 번 로그인해도 순 방문자는 1명으로 센다") + void countsRepeatLoginAsSingleUniqueVisitor() throws Exception { + User user = fixtures.createUser(); + saveMissionLog(user, MissionType.USER_LOGIN, null); + saveMissionLog(user, MissionType.USER_LOGIN, null); + saveMissionLog(user, MissionType.USER_LOGIN, null); + + mockMvc.perform(get("/admin/analysis") + .param("startDate", TODAY.toString()) + .param("endDate", TODAY.toString())) + .andExpect(status().isOk()) + .andExpect(model().attribute("periodVisitCount", 3L)) + .andExpect(model().attribute("periodUniqueVisitorCount", 1L)); + } + + @Test + @DisplayName("시작일이 종료일보다 뒤면 두 값을 맞바꿔 처리한다") + void swapsReversedDateRange() throws Exception { + mockMvc.perform(get("/admin/analysis") + .param("startDate", TODAY.toString()) + .param("endDate", TODAY.minusDays(6).toString())) + .andExpect(status().isOk()) + .andExpect(model().attribute("startDate", TODAY.minusDays(6))) + .andExpect(model().attribute("endDate", TODAY)); + } + + @Test + @DisplayName("시작일과 종료일이 같은 하루 조회도 500 없이 처리한다") + void handlesSingleDayRange() throws Exception { + User user = fixtures.createUser(); + saveMissionLog(user, MissionType.USER_LOGIN, null); + + MvcResult result = mockMvc.perform(get("/admin/analysis") + .param("startDate", TODAY.toString()) + .param("endDate", TODAY.toString())) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(dailyMap(result, "dailyVisits")) + .as("하루짜리 조회는 정확히 하루치 항목만 있어야 한다") + .hasSize(1); + } + + @Test + @DisplayName("기간 밖의 기록은 집계에서 빠진다") + void excludesRecordsOutsideRange() throws Exception { + User user = fixtures.createUser(); + var oldLog = saveMissionLog(user, MissionType.USER_LOGIN, null); + forceCreatedAt("mission_log", oldLog.getId(), TODAY.minusDays(40).atTime(12, 0)); + + mockMvc.perform(get("/admin/analysis") + .param("startDate", TODAY.minusDays(6).toString()) + .param("endDate", TODAY.toString())) + .andExpect(status().isOk()) + .andExpect(model().attribute("periodVisitCount", 0L)); + } + + @Test + @DisplayName("날짜 형식이 잘못되면 400으로 거절한다") + void rejectsMalformedDate() throws Exception { + mockMvc.perform(get("/admin/analysis").param("startDate", "2026-13-45")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("일자별 신규 가입자") + class DailyNewUsers { + + @Test + @DisplayName("해당 날짜에 가입한 사용자만 보여준다") + void showsUsersJoinedOnGivenDate() throws Exception { + User today = fixtures.createUser(); + User past = fixtures.createUser(); + forceCreatedAt("user", past.getId(), TODAY.minusDays(3).atTime(9, 0)); + + MvcResult result = mockMvc.perform(get("/admin/analysis/daily-new-users") + .param("date", TODAY.toString())) + .andExpect(status().isOk()) + .andExpect(view().name("daily-users")) + .andExpect(model().attribute("type", "new")) + .andExpect(model().attribute("date", TODAY)) + .andReturn(); + + @SuppressWarnings("unchecked") + List users = (List) result.getModelAndView().getModel().get("users"); + assertThat(users).extracting(UserResponseDto::userId) + .contains(today.getId()) + .doesNotContain(past.getId()); + } + + @Test + @DisplayName("가입자가 없는 날짜는 빈 목록을 준다") + void returnsEmptyListForDateWithoutSignups() throws Exception { + MvcResult result = mockMvc.perform(get("/admin/analysis/daily-new-users") + .param("date", TODAY.minusYears(5).toString())) + .andExpect(status().isOk()) + .andReturn(); + + @SuppressWarnings("unchecked") + List users = (List) result.getModelAndView().getModel().get("users"); + assertThat(users).isEmpty(); + } + + @Test + @DisplayName("date 파라미터가 없으면 400으로 거절한다") + void rejectsMissingDate() throws Exception { + mockMvc.perform(get("/admin/analysis/daily-new-users")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("일자별 출석 유저") + class DailyActiveUsers { + + @Test + @DisplayName("로그인 미션을 남긴 사용자만 출석으로 본다") + void showsOnlyUsersWithLoginMission() throws Exception { + User active = fixtures.createUser(); + User inactive = fixtures.createUser(); + saveMissionLog(active, MissionType.USER_LOGIN, null); + saveMissionLog(inactive, MissionType.PROBLEM_WRITE, null); + + MvcResult result = mockMvc.perform(get("/admin/analysis/daily-active-users") + .param("date", TODAY.toString())) + .andExpect(status().isOk()) + .andExpect(view().name("daily-users")) + .andExpect(model().attribute("type", "active")) + .andReturn(); + + @SuppressWarnings("unchecked") + List users = (List) result.getModelAndView().getModel().get("users"); + assertThat(users).extracting(UserResponseDto::userId) + .as("문제 작성 미션은 출석이 아니다") + .containsExactly(active.getId()); + } + + @Test + @DisplayName("같은 사용자가 두 번 로그인해도 한 번만 나온다") + void deduplicatesRepeatedLogins() throws Exception { + User user = fixtures.createUser(); + saveMissionLog(user, MissionType.USER_LOGIN, null); + saveMissionLog(user, MissionType.USER_LOGIN, null); + + MvcResult result = mockMvc.perform(get("/admin/analysis/daily-active-users") + .param("date", TODAY.toString())) + .andExpect(status().isOk()) + .andReturn(); + + @SuppressWarnings("unchecked") + List users = (List) result.getModelAndView().getModel().get("users"); + assertThat(users).hasSize(1); + } + + @Test + @DisplayName("date 파라미터가 없으면 400으로 거절한다") + void rejectsMissingDate() throws Exception { + mockMvc.perform(get("/admin/analysis/daily-active-users")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminControllerTest.java new file mode 100644 index 00000000..4bbbce6a --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminControllerTest.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminController") +class AdminControllerTest extends AdminTestSupport { + + @BeforeEach + void loginAsAdmin() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + } + + @Test + @DisplayName("관리자 메인 화면을 렌더링한다") + void rendersAdminMainPage() throws Exception { + mockMvc.perform(get("/admin/main")) + .andExpect(status().isOk()) + .andExpect(view().name("admin")); + } + + @Test + @DisplayName("이미지 뷰어는 넘겨받은 url 을 그대로 모델에 담는다") + void putsImageUrlIntoModel() throws Exception { + mockMvc.perform(get("/admin/user/image/view").param("url", "https://cdn.test.ono/problem/1.png")) + .andExpect(status().isOk()) + .andExpect(view().name("image")) + .andExpect(model().attribute("imageUrl", "https://cdn.test.ono/problem/1.png")); + } + + @Test + @DisplayName("url 파라미터가 없으면 400으로 거절한다") + void rejectsMissingUrlParameter() throws Exception { + mockMvc.perform(get("/admin/user/image/view")) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackControllerTest.java new file mode 100644 index 00000000..27ee3cb0 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminFeedbackControllerTest.java @@ -0,0 +1,181 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.feedback.dto.FeedbackResponseDto; +import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MvcResult; + +import java.time.LocalDateTime; +import java.util.List; + +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.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminFeedbackController") +class AdminFeedbackControllerTest extends AdminTestSupport { + + @BeforeEach + void loginAsAdmin() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + } + + @SuppressWarnings("unchecked") + private List feedbacksOf(MvcResult result) { + return (List) result.getModelAndView().getModel().get("feedbacks"); + } + + @Nested + @DisplayName("피드백 목록") + class FeedbackList { + + @Test + @DisplayName("피드백이 없어도 0 기반 집계를 돌려주고 평균 NPS 는 null 이다") + void rendersZeroBasedResultWhenEmpty() throws Exception { + MvcResult result = mockMvc.perform(get("/admin/feedbacks")) + .andExpect(status().isOk()) + .andExpect(view().name("admin-feedback")) + .andExpect(model().attribute("totalCount", 0L)) + .andExpect(model().attribute("totalPages", 0)) + .andExpect(model().attribute("hasPreviousBlock", false)) + .andExpect(model().attribute("hasNextBlock", false)) + .andReturn(); + + assertThat(feedbacksOf(result)).isEmpty(); + assertThat(result.getModelAndView().getModel().get("averageNps")) + .as("표본이 없을 때 평균을 0으로 꾸며내면 실제 NPS 0점과 구분되지 않는다") + .isNull(); + } + + @Test + @DisplayName("제출 시각 내림차순으로 보여준다") + void ordersBySubmittedAtDesc() throws Exception { + UserFeedback older = userFeedbackRepository.save(UserFeedback.builder() + .npsScore(3) + .submittedAt(LocalDateTime.now().minusDays(2)) + .build()); + UserFeedback newer = userFeedbackRepository.save(UserFeedback.builder() + .npsScore(9) + .submittedAt(LocalDateTime.now()) + .build()); + + MvcResult result = mockMvc.perform(get("/admin/feedbacks")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalCount", 2L)) + .andReturn(); + + assertThat(feedbacksOf(result)) + .extracting(FeedbackResponseDto::getId) + .containsExactly(newer.getId(), older.getId()); + } + + @Test + @DisplayName("NPS 평균은 점수가 있는 응답만으로 계산한다") + void averagesOnlyScoredFeedback() throws Exception { + saveFeedback(10, "시험 대비"); + saveFeedback(6, "숙제"); + userFeedbackRepository.save(UserFeedback.builder() + .submittedAt(LocalDateTime.now()) + .build()); + + MvcResult result = mockMvc.perform(get("/admin/feedbacks")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalCount", 3L)) + .andReturn(); + + assertThat((Double) result.getModelAndView().getModel().get("averageNps")) + .as("NPS 를 남기지 않은 응답까지 분모에 넣으면 평균이 낮게 왜곡된다") + .isEqualTo(8.0); + } + + @Test + @DisplayName("size 로 페이지를 끊는다") + void paginatesFeedback() throws Exception { + saveFeedback(1, "a"); + saveFeedback(2, "b"); + saveFeedback(3, "c"); + + MvcResult result = mockMvc.perform(get("/admin/feedbacks").param("size", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 2)) + .andReturn(); + + assertThat(feedbacksOf(result)).hasSize(2); + } + + @ParameterizedTest(name = "page={0}") + @ValueSource(ints = {-1, -20}) + @DisplayName("음수 page 는 500이 아니라 0페이지로 보정한다") + void clampsNegativePage(int page) throws Exception { + saveFeedback(5, "목적"); + + mockMvc.perform(get("/admin/feedbacks").param("page", String.valueOf(page))) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 0)); + } + + @ParameterizedTest(name = "size={0}") + @ValueSource(ints = {0, -1}) + @DisplayName("0 이하 size 는 500이 아니라 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + saveFeedback(5, "목적"); + + mockMvc.perform(get("/admin/feedbacks").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + + @Test + @DisplayName("size 가 과도하게 커도 500을 내지 않는다") + void allowsOversizedPageSize() throws Exception { + saveFeedback(5, "목적"); + + mockMvc.perform(get("/admin/feedbacks").param("size", "100000")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 1)); + } + } + + @Nested + @DisplayName("피드백 상세") + class FeedbackDetail { + + @Test + @DisplayName("저장된 응답 내용을 그대로 보여준다") + void showsStoredAnswers() throws Exception { + UserFeedback feedback = saveFeedback(9, "시험 대비,숙제"); + + MvcResult result = mockMvc.perform(get("/admin/feedbacks/{id}", feedback.getId())) + .andExpect(status().isOk()) + .andExpect(view().name("admin-feedback-detail")) + .andReturn(); + + FeedbackResponseDto dto = (FeedbackResponseDto) result.getModelAndView().getModel().get("feedback"); + assertThat(dto.getId()).isEqualTo(feedback.getId()); + assertThat(dto.getNpsScore()).isEqualTo(9); + assertThat(dto.getUsagePurpose()).isEqualTo("시험 대비,숙제"); + } + + @Test + @DisplayName("없는 피드백을 조회하면 500이 아니라 404로 응답한다") + void returnsNotFoundForUnknownId() throws Exception { + mockMvc.perform(get("/admin/feedbacks/{id}", 999_999L)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("id 가 숫자가 아니면 400으로 거절한다") + void rejectsNonNumericId() throws Exception { + mockMvc.perform(get("/admin/feedbacks/{id}", "abc")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteControllerTest.java new file mode 100644 index 00000000..3da39a40 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminPracticeNoteControllerTest.java @@ -0,0 +1,199 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; +import com.aisip.OnO.backend.admin.dto.AdminPracticeNoteResponseDto; +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +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.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminPracticeNoteController") +class AdminPracticeNoteControllerTest extends AdminTestSupport { + + private User owner; + + @BeforeEach + void setUp() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + owner = fixtures.createUser(); + } + + @SuppressWarnings("unchecked") + private List attribute(MvcResult result, String name) { + return (List) result.getModelAndView().getModel().get(name); + } + + @Nested + @DisplayName("복습노트 목록") + class PracticeNoteList { + + @Test + @DisplayName("복습노트가 없어도 0 기반 집계를 돌려준다") + void rendersZeroBasedResultWhenEmpty() throws Exception { + mockMvc.perform(get("/admin/practice-notes")) + .andExpect(status().isOk()) + .andExpect(view().name("practice-notes")) + .andExpect(model().attribute("totalPracticeNotes", 0L)) + .andExpect(model().attribute("noteTotalPages", 0)) + .andExpect(model().attribute("notePageBlockStart", 0)) + .andExpect(model().attribute("notePageBlockEnd", 0)) + .andExpect(model().attribute("hasPreviousNoteBlock", false)) + .andExpect(model().attribute("hasNextNoteBlock", false)); + } + + @Test + @DisplayName("복습노트에 작성자 정보와 문제 수를 붙여 보여준다") + void showsOwnerAndProblemCount() throws Exception { + PracticeNote note = savePracticeNote(owner.getId(), "미적분 복습"); + + MvcResult result = mockMvc.perform(get("/admin/practice-notes")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPracticeNotes", 1L)) + .andReturn(); + + List notes = attribute(result, "practiceNotes"); + assertThat(notes).singleElement().satisfies(dto -> { + assertThat(dto.practiceNoteId()).isEqualTo(note.getId()); + assertThat(dto.userId()).isEqualTo(owner.getId()); + assertThat(dto.userName()).isEqualTo(owner.getName()); + assertThat(dto.practiceTitle()).isEqualTo("미적분 복습"); + assertThat(dto.problemCount()) + .as("문제가 없는 복습노트는 null 이 아니라 0 이어야 한다") + .isEqualTo(0L); + assertThat(dto.practiceCount()).isEqualTo(0L); + }); + } + + @Test + @DisplayName("notePage 로 페이지를 넘길 수 있다") + void paginatesByNotePage() throws Exception { + savePracticeNote(owner.getId(), "노트1"); + savePracticeNote(owner.getId(), "노트2"); + savePracticeNote(owner.getId(), "노트3"); + + MvcResult result = mockMvc.perform(get("/admin/practice-notes") + .param("notePage", "1") + .param("size", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("notePage", 1)) + .andExpect(model().attribute("noteTotalPages", 2)) + .andReturn(); + + assertThat(attribute(result, "practiceNotes")).hasSize(1); + } + + @ParameterizedTest(name = "notePage={0}") + @ValueSource(ints = {-1, -30}) + @DisplayName("음수 notePage 는 0페이지로 보정한다") + void clampsNegativeNotePage(int notePage) throws Exception { + mockMvc.perform(get("/admin/practice-notes").param("notePage", String.valueOf(notePage))) + .andExpect(status().isOk()) + .andExpect(model().attribute("notePage", 0)); + } + + @ParameterizedTest(name = "size={0}") + @ValueSource(ints = {0, -1}) + @DisplayName("0 이하 size 는 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + mockMvc.perform(get("/admin/practice-notes").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + + @Test + @DisplayName("size 가 과도하게 커도 500을 내지 않는다") + void allowsOversizedPageSize() throws Exception { + savePracticeNote(owner.getId(), "노트"); + + mockMvc.perform(get("/admin/practice-notes").param("size", "100000")) + .andExpect(status().isOk()) + .andExpect(model().attribute("noteTotalPages", 1)); + } + } + + @Nested + @DisplayName("복습 기록 목록") + class PracticeLogList { + + @Test + @DisplayName("복습 기록이 없어도 0 기반 집계를 돌려준다") + void rendersZeroBasedResultWhenEmpty() throws Exception { + mockMvc.perform(get("/admin/practice-logs")) + .andExpect(status().isOk()) + .andExpect(view().name("practice-logs")) + .andExpect(model().attribute("totalPracticeLogs", 0L)) + .andExpect(model().attribute("logTotalPages", 0)) + .andExpect(model().attribute("hasPreviousLogBlock", false)) + .andExpect(model().attribute("hasNextLogBlock", false)); + } + + @Test + @DisplayName("NOTE_PRACTICE 미션 기록만 복습 기록으로 집계한다") + void countsOnlyNotePracticeMissions() throws Exception { + PracticeNote note = savePracticeNote(owner.getId(), "복습노트"); + saveMissionLog(owner, MissionType.NOTE_PRACTICE, note.getId()); + saveMissionLog(owner, MissionType.USER_LOGIN, null); + saveMissionLog(owner, MissionType.PROBLEM_WRITE, null); + + MvcResult result = mockMvc.perform(get("/admin/practice-logs")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPracticeLogs", 1L)) + .andReturn(); + + List logs = attribute(result, "practiceLogs"); + assertThat(logs).singleElement().satisfies(dto -> { + assertThat(dto.userId()).isEqualTo(owner.getId()); + assertThat(dto.practiceNoteId()).isEqualTo(note.getId()); + assertThat(dto.practiceTitle()).isEqualTo("복습노트"); + assertThat(dto.point()).isEqualTo(MissionType.NOTE_PRACTICE.getPoint()); + }); + } + + @Test + @DisplayName("참조된 복습노트가 사라졌어도 500 없이 '-' 로 표시한다") + void showsDashWhenReferencedNoteIsGone() throws Exception { + saveMissionLog(owner, MissionType.NOTE_PRACTICE, 999_999L); + + MvcResult result = mockMvc.perform(get("/admin/practice-logs")) + .andExpect(status().isOk()) + .andReturn(); + + List logs = attribute(result, "practiceLogs"); + assertThat(logs).singleElement() + .satisfies(dto -> assertThat(dto.practiceTitle()).isEqualTo("-")); + } + + @ParameterizedTest(name = "logPage={0}") + @ValueSource(ints = {-1, -7}) + @DisplayName("음수 logPage 는 0페이지로 보정한다") + void clampsNegativeLogPage(int logPage) throws Exception { + mockMvc.perform(get("/admin/practice-logs").param("logPage", String.valueOf(logPage))) + .andExpect(status().isOk()) + .andExpect(model().attribute("logPage", 0)); + } + + @ParameterizedTest(name = "size={0}") + @ValueSource(ints = {0, -1}) + @DisplayName("0 이하 size 는 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + mockMvc.perform(get("/admin/practice-logs").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminProblemControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminProblemControllerTest.java new file mode 100644 index 00000000..760f9d42 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminProblemControllerTest.java @@ -0,0 +1,183 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.folder.dto.FolderResponseDto; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.user.dto.UserResponseDto; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +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.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminProblemController") +class AdminProblemControllerTest extends AdminTestSupport { + + private User owner; + private Folder folder; + + @BeforeEach + void setUp() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + owner = fixtures.createUser(); + folder = fixtures.createRootFolder(owner.getId()); + } + + @SuppressWarnings("unchecked") + private List problemsOf(MvcResult result) { + return (List) result.getModelAndView().getModel().get("problems"); + } + + @Nested + @DisplayName("문제 목록") + class ProblemList { + + @Test + @DisplayName("문제가 하나도 없어도 0 기반 집계를 돌려준다") + void rendersZeroBasedResultWhenNoProblemExists() throws Exception { + mockMvc.perform(get("/admin/problems")) + .andExpect(status().isOk()) + .andExpect(view().name("problems")) + .andExpect(model().attribute("totalProblems", 0L)) + .andExpect(model().attribute("totalPages", 0)) + .andExpect(model().attribute("pageStartItem", 0)) + .andExpect(model().attribute("pageEndItem", 0)); + } + + @Test + @DisplayName("등록된 문제를 페이지 단위로 보여준다") + void paginatesProblems() throws Exception { + saveProblem(owner.getId(), folder, "문제1"); + saveProblem(owner.getId(), folder, "문제2"); + saveProblem(owner.getId(), folder, "문제3"); + + MvcResult result = mockMvc.perform(get("/admin/problems").param("size", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalProblems", 3L)) + .andExpect(model().attribute("totalPages", 2)) + .andReturn(); + + assertThat(problemsOf(result)).hasSize(2); + } + + @Test + @DisplayName("다른 사용자의 문제도 함께 보인다 - 관리자 화면은 전체 조회다") + void showsProblemsOfEveryUser() throws Exception { + User other = fixtures.createOtherUser(); + Folder otherFolder = fixtures.createRootFolder(other.getId()); + saveProblem(owner.getId(), folder, "내 문제"); + saveProblem(other.getId(), otherFolder, "남의 문제"); + + MvcResult result = mockMvc.perform(get("/admin/problems").param("size", "50")) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(problemsOf(result)) + .as("전체 조회이므로 관리자에게는 모든 사용자의 문제가 보여야 한다") + .hasSize(2); + } + + @ParameterizedTest(name = "page={0}") + @ValueSource(ints = {-1, -50}) + @DisplayName("음수 page 는 0페이지로 보정한다") + void clampsNegativePage(int page) throws Exception { + saveProblem(owner.getId(), folder, "문제"); + + mockMvc.perform(get("/admin/problems").param("page", String.valueOf(page))) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 0)); + } + + @ParameterizedTest(name = "size={0}") + @ValueSource(ints = {0, -1}) + @DisplayName("0 이하 size 는 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + saveProblem(owner.getId(), folder, "문제"); + + mockMvc.perform(get("/admin/problems").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + + @Test + @DisplayName("마지막 페이지를 넘는 page 는 마지막 페이지로 되돌려 빈 화면을 보여주지 않는다") + void clampsPageBeyondLastPage() throws Exception { + saveProblem(owner.getId(), folder, "문제1"); + saveProblem(owner.getId(), folder, "문제2"); + + MvcResult result = mockMvc.perform(get("/admin/problems").param("page", "99").param("size", "1")) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 1)) + .andReturn(); + + assertThat(problemsOf(result)) + .as("범위를 벗어난 page 요청이 빈 목록이 되면 관리자가 데이터가 사라진 줄 안다") + .hasSize(1); + } + + @Test + @DisplayName("size 가 전체 건수보다 커도 한 페이지로 묶어 준다") + void allowsOversizedPageSize() throws Exception { + saveProblem(owner.getId(), folder, "문제"); + + mockMvc.perform(get("/admin/problems").param("size", "10000")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 1)); + } + } + + @Nested + @DisplayName("문제 상세") + class ProblemDetail { + + @Test + @DisplayName("문제와 함께 폴더·작성자·풀이 기록을 모아 보여준다") + void showsProblemWithFolderAndOwner() throws Exception { + Problem problem = saveProblem(owner.getId(), folder, "삼각함수 실수"); + + MvcResult result = mockMvc.perform(get("/admin/problem/{problemId}", problem.getId())) + .andExpect(status().isOk()) + .andExpect(view().name("problem")) + .andExpect(model().attribute("problemSolveCount", 0)) + .andReturn(); + + ProblemResponseDto problemDto = (ProblemResponseDto) result.getModelAndView().getModel().get("problem"); + FolderResponseDto folderDto = (FolderResponseDto) result.getModelAndView().getModel().get("folder"); + UserResponseDto userDto = (UserResponseDto) result.getModelAndView().getModel().get("user"); + + assertThat(problemDto.problemId()).isEqualTo(problem.getId()); + assertThat(folderDto.folderId()).isEqualTo(folder.getId()); + assertThat(userDto.userId()) + .as("작성자를 잘못 짚으면 관리자가 엉뚱한 사용자를 제재하게 된다") + .isEqualTo(owner.getId()); + } + + @Test + @DisplayName("없는 문제를 조회하면 500이 아니라 404로 응답한다") + void returnsNotFoundForUnknownProblem() throws Exception { + mockMvc.perform(get("/admin/problem/{problemId}", 999_999L)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("problemId 가 숫자가 아니면 400으로 거절한다") + void rejectsNonNumericProblemId() throws Exception { + mockMvc.perform(get("/admin/problem/{problemId}", "abc")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomControllerTest.java new file mode 100644 index 00000000..0c05e696 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminStudyRoomControllerTest.java @@ -0,0 +1,167 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomDetailDto; +import com.aisip.OnO.backend.admin.dto.AdminStudyRoomSummaryDto; +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +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.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminStudyRoomController") +class AdminStudyRoomControllerTest extends AdminTestSupport { + + private User host; + + @BeforeEach + void setUp() { + authenticateAs(createAdminUser().getId(), "ROLE_ADMIN"); + host = fixtures.createUser(); + } + + @SuppressWarnings("unchecked") + private List roomsOf(MvcResult result) { + return (List) result.getModelAndView().getModel().get("rooms"); + } + + @Nested + @DisplayName("스터디룸 목록") + class RoomList { + + @Test + @DisplayName("스터디룸이 없어도 0 기반 집계를 돌려준다") + void rendersZeroBasedResultWhenEmpty() throws Exception { + MvcResult result = mockMvc.perform(get("/admin/study-rooms")) + .andExpect(status().isOk()) + .andExpect(view().name("admin-study-rooms")) + .andExpect(model().attribute("totalCount", 0L)) + .andExpect(model().attribute("totalPages", 0)) + .andExpect(model().attribute("pageBlockStart", 0)) + .andExpect(model().attribute("pageBlockEnd", 0)) + .andExpect(model().attribute("hasPreviousBlock", false)) + .andReturn(); + + assertThat(roomsOf(result)).isEmpty(); + } + + @Test + @DisplayName("방마다 멤버 수와 공유 문제 수를 집계해 보여준다") + void aggregatesMemberAndSharedProblemCount() throws Exception { + StudyRoom room = saveStudyRoom("고3 수학방", host); + + MvcResult result = mockMvc.perform(get("/admin/study-rooms")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalCount", 1L)) + .andReturn(); + + assertThat(roomsOf(result)).singleElement().satisfies(dto -> { + assertThat(dto.getId()).isEqualTo(room.getId()); + assertThat(dto.getName()).isEqualTo("고3 수학방"); + assertThat(dto.getMemberCount()).isEqualTo(1L); + assertThat(dto.getSharedProblemCount()) + .as("공유 문제가 없는 방은 null 이 아니라 0 이어야 한다") + .isEqualTo(0L); + }); + } + + @Test + @DisplayName("size 로 페이지를 끊는다") + void paginatesRooms() throws Exception { + saveStudyRoom("방1", host); + saveStudyRoom("방2", host); + saveStudyRoom("방3", host); + + MvcResult result = mockMvc.perform(get("/admin/study-rooms").param("size", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 2)) + .andReturn(); + + assertThat(roomsOf(result)).hasSize(2); + } + + @ParameterizedTest(name = "page={0}") + @ValueSource(ints = {-1, -20}) + @DisplayName("음수 page 는 500이 아니라 0페이지로 보정한다") + void clampsNegativePage(int page) throws Exception { + saveStudyRoom("방", host); + + mockMvc.perform(get("/admin/study-rooms").param("page", String.valueOf(page))) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 0)); + } + + @ParameterizedTest(name = "size={0}") + @ValueSource(ints = {0, -1}) + @DisplayName("0 이하 size 는 500이 아니라 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + saveStudyRoom("방", host); + + mockMvc.perform(get("/admin/study-rooms").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + + @Test + @DisplayName("size 가 과도하게 커도 500을 내지 않는다") + void allowsOversizedPageSize() throws Exception { + saveStudyRoom("방", host); + + mockMvc.perform(get("/admin/study-rooms").param("size", "100000")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 1)); + } + } + + @Nested + @DisplayName("스터디룸 상세") + class RoomDetail { + + @Test + @DisplayName("방장과 멤버 목록을 보여준다") + void showsMembers() throws Exception { + StudyRoom room = saveStudyRoom("고3 수학방", host); + + MvcResult result = mockMvc.perform(get("/admin/study-rooms/{id}", room.getId())) + .andExpect(status().isOk()) + .andExpect(view().name("admin-study-room-detail")) + .andReturn(); + + AdminStudyRoomDetailDto dto = + (AdminStudyRoomDetailDto) result.getModelAndView().getModel().get("room"); + assertThat(dto.getId()).isEqualTo(room.getId()); + assertThat(dto.getHostUserId()).isEqualTo(host.getId()); + assertThat(dto.getMembers()) + .extracting(AdminStudyRoomDetailDto.MemberInfo::getUserId) + .containsExactly(host.getId()); + assertThat(dto.getChallenges()).isEmpty(); + assertThat(dto.getSharedProblemCount()).isZero(); + } + + @Test + @DisplayName("없는 스터디룸을 조회하면 500이 아니라 404로 응답한다") + void returnsNotFoundForUnknownRoom() throws Exception { + mockMvc.perform(get("/admin/study-rooms/{id}", 999_999L)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("id 가 숫자가 아니면 400으로 거절한다") + void rejectsNonNumericId() throws Exception { + mockMvc.perform(get("/admin/study-rooms/{id}", "abc")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java new file mode 100644 index 00000000..31bb741a --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/controller/AdminUserControllerTest.java @@ -0,0 +1,380 @@ +package com.aisip.OnO.backend.admin.controller; + +import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@DisplayName("AdminUserController") +class AdminUserControllerTest extends AdminTestSupport { + + private User admin; + + @BeforeEach + void loginAsAdmin() { + admin = createAdminUser(); + authenticateAs(admin.getId(), "ROLE_ADMIN"); + } + + @SuppressWarnings("unchecked") + private List usersOf(MvcResult result) { + return (List) result.getModelAndView().getModel().get("users"); + } + + @Nested + @DisplayName("사용자 목록") + class UserList { + + @Test + @DisplayName("사용자가 하나도 없어도 500이 아니라 0 기반 집계를 돌려준다") + void rendersZeroBasedResultWhenNoUserExists() throws Exception { + // User 는 소프트 삭제라 deleteAll() 로는 행이 남는다. 집계가 0인 상황을 만들려면 물리 삭제해야 한다. + jdbcTemplate.update("DELETE FROM `user`"); + + mockMvc.perform(get("/admin/users")) + .andExpect(status().isOk()) + .andExpect(view().name("users")) + .andExpect(model().attribute("totalUsers", 0L)) + .andExpect(model().attribute("totalPages", 0)) + .andExpect(model().attribute("pageStartItem", 0)) + .andExpect(model().attribute("pageEndItem", 0)) + .andExpect(model().attribute("hasPreviousBlock", false)) + .andExpect(model().attribute("hasNextBlock", false)); + } + + @Test + @DisplayName("전체 사용자를 페이지 단위로 끊어서 보여준다") + void paginatesUsers() throws Exception { + fixtures.createUser(); + fixtures.createUser(); + fixtures.createUser(); + + MvcResult firstPage = mockMvc.perform(get("/admin/users").param("size", "2")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalUsers", 4L)) + .andExpect(model().attribute("totalPages", 2)) + .andExpect(model().attribute("currentPage", 0)) + .andExpect(model().attribute("pageStartItem", 1)) + .andExpect(model().attribute("pageEndItem", 2)) + .andReturn(); + + assertThat(usersOf(firstPage)) + .as("size=2 를 줬으면 한 페이지에 2명만 나와야 한다") + .hasSize(2); + + MvcResult secondPage = mockMvc.perform(get("/admin/users").param("page", "1").param("size", "2")) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(usersOf(secondPage)).hasSize(2); + assertThat(usersOf(secondPage)) + .as("페이지가 겹치면 같은 사용자가 두 번 보인다") + .extracting(AdminUserResponseDto::userId) + .doesNotContainAnyElementsOf(usersOf(firstPage).stream().map(AdminUserResponseDto::userId).toList()); + } + + @Test + @DisplayName("사용자마다 소유한 문제 수를 함께 집계한다") + void aggregatesProblemCountPerUser() throws Exception { + User target = fixtures.createUser(); + Folder folder = fixtures.createRootFolder(target.getId()); + saveProblem(target.getId(), folder, "문제1"); + saveProblem(target.getId(), folder, "문제2"); + + MvcResult result = mockMvc.perform(get("/admin/users").param("size", "50")) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(usersOf(result)) + .filteredOn(dto -> dto.userId().equals(target.getId())) + .singleElement() + .satisfies(dto -> { + assertThat(dto.problemCount()).isEqualTo(2L); + assertThat(dto.totalStudyLevel()).as("신규 사용자는 레벨 1에서 시작한다").isEqualTo(1L); + }); + + assertThat(usersOf(result)) + .filteredOn(dto -> dto.userId().equals(admin.getId())) + .singleElement() + .satisfies(dto -> assertThat(dto.problemCount()) + .as("문제가 없는 사용자는 null 이 아니라 0 이어야 한다") + .isEqualTo(0L)); + } + + @ParameterizedTest(name = "page={0} 는 0페이지로 보정된다") + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + @DisplayName("음수 page 는 500을 내지 않고 0페이지로 보정한다") + void clampsNegativePage(int page) throws Exception { + fixtures.createUser(); + + mockMvc.perform(get("/admin/users").param("page", String.valueOf(page))) + .andExpect(status().isOk()) + .andExpect(model().attribute("currentPage", 0)); + } + + @ParameterizedTest(name = "size={0} 은 1 이상으로 보정된다") + @ValueSource(ints = {0, -1, Integer.MIN_VALUE}) + @DisplayName("0 이하 size 는 500을 내지 않고 1로 보정한다") + void clampsNonPositiveSize(int size) throws Exception { + fixtures.createUser(); + + mockMvc.perform(get("/admin/users").param("size", String.valueOf(size))) + .andExpect(status().isOk()) + .andExpect(model().attribute("size", 1)); + } + + @Test + @DisplayName("size 가 전체 건수보다 커도 한 페이지에 전부 담아 준다") + void allowsOversizedPageSize() throws Exception { + fixtures.createUser(); + fixtures.createUser(); + + MvcResult result = mockMvc.perform(get("/admin/users").param("size", "10000")) + .andExpect(status().isOk()) + .andExpect(model().attribute("totalPages", 1)) + .andReturn(); + + assertThat(usersOf(result)).hasSize(3); + } + + @Test + @DisplayName("전체 페이지 수를 넘는 page 를 요청해도 빈 목록으로 응답한다") + void returnsEmptyListBeyondLastPage() throws Exception { + fixtures.createUser(); + + MvcResult result = mockMvc.perform(get("/admin/users").param("page", "500").param("size", "20")) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(usersOf(result)).isEmpty(); + } + + @Test + @DisplayName("정렬 파라미터를 그대로 모델에 실어 화면 상태를 유지한다") + void keepsSortParametersInModel() throws Exception { + mockMvc.perform(get("/admin/users").param("sortBy", "name").param("direction", "asc")) + .andExpect(status().isOk()) + .andExpect(model().attribute("sortBy", "name")) + .andExpect(model().attribute("direction", "asc")); + } + } + + @Nested + @DisplayName("사용자 상세") + class UserDetail { + + @Test + @DisplayName("사용자의 문제·폴더·복습노트·미션 기록을 모두 모아 보여준다") + void showsAllOwnedDataOfUser() throws Exception { + User target = fixtures.createUser(); + Folder folder = fixtures.createRootFolder(target.getId()); + saveProblem(target.getId(), folder, "메모"); + savePracticeNote(target.getId(), "복습노트"); + saveMissionLog(target, MissionType.USER_LOGIN, null); + + mockMvc.perform(get("/admin/user/{userId}", target.getId())) + .andExpect(status().isOk()) + .andExpect(view().name("user")) + .andExpect(model().attribute("problemCount", 1L)) + .andExpect(model().attribute("folderCount", 1)) + .andExpect(model().attribute("practiceNoteCount", 1)) + .andExpect(model().attribute("missionLogCount", 1)); + } + + @Test + @DisplayName("다른 사용자의 데이터는 섞이지 않는다") + void doesNotMixDataOfOtherUsers() throws Exception { + User target = fixtures.createUser(); + User other = fixtures.createOtherUser(); + Folder otherFolder = fixtures.createRootFolder(other.getId()); + saveProblem(other.getId(), otherFolder, "남의 문제"); + + mockMvc.perform(get("/admin/user/{userId}", target.getId())) + .andExpect(status().isOk()) + .andExpect(model().attribute("problemCount", 0L)) + .andExpect(model().attribute("folderCount", 0)); + } + + @Test + @DisplayName("없는 사용자를 조회하면 500이 아니라 404로 응답한다") + void returnsNotFoundForUnknownUser() throws Exception { + mockMvc.perform(get("/admin/user/{userId}", 999_999L)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("사용자 수정") + class UpdateUser { + + @Test + @DisplayName("이름·이메일을 수정하면 저장하고 상세 화면으로 리다이렉트한다") + void updatesUserAndRedirects() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}", target.getId()) + .param("name", "바뀐이름") + .param("email", "changed@test.ono")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/admin/user/" + target.getId())); + + assertThat(userRepository.findById(target.getId())) + .get() + .satisfies(user -> { + assertThat(user.getName()).isEqualTo("바뀐이름"); + assertThat(user.getEmail()).isEqualTo("changed@test.ono"); + }); + } + + @Test + @DisplayName("빈 값으로 보낸 항목은 기존 값을 덮어쓰지 않는다") + void keepsExistingValuesForBlankFields() throws Exception { + User target = fixtures.createUser(); + String originalName = target.getName(); + + mockMvc.perform(post("/admin/user/{userId}", target.getId()) + .param("name", "") + .param("email", "only-email@test.ono")) + .andExpect(status().is3xxRedirection()); + + assertThat(userRepository.findById(target.getId())) + .get() + .satisfies(user -> { + assertThat(user.getName()) + .as("빈 문자열로 이름을 날려버리면 운영 실수가 곧 데이터 손실이 된다") + .isEqualTo(originalName); + assertThat(user.getEmail()).isEqualTo("only-email@test.ono"); + }); + } + } + + @Nested + @DisplayName("레벨 수동 조정") + class UpdateUserLevel { + + @Test + @DisplayName("능력치 종류별로 레벨과 포인트를 직접 설정할 수 있다") + void setsLevelAndPointByAbilityType() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}/level", target.getId()) + .param("levelType", "attendance") + .param("levelValue", "7") + .param("pointValue", "42")) + .andExpect(status().isOk()); + + assertThat(userRepository.findById(target.getId())) + .get() + .satisfies(user -> { + assertThat(user.getUserMissionStatus().getAttendanceLevel()).isEqualTo(7L); + assertThat(user.getUserMissionStatus().getAttendancePoint()).isEqualTo(42L); + assertThat(user.getUserMissionStatus().getNoteWriteLevel()) + .as("지정하지 않은 능력치까지 건드리면 안 된다") + .isEqualTo(1L); + }); + } + + @ParameterizedTest(name = "levelType={0}") + @ValueSource(strings = {"attendance", "noteWrite", "problemPractice", "notePractice", "totalStudy"}) + @DisplayName("지원하는 levelType 다섯 가지는 모두 성공한다") + void acceptsEverySupportedLevelType(String levelType) throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}/level", target.getId()) + .param("levelType", levelType) + .param("levelValue", "3") + .param("pointValue", "1")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("알 수 없는 levelType 은 500이 아니라 404로 거절한다") + void rejectsUnknownLevelType() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}/level", target.getId()) + .param("levelType", "unknown") + .param("levelValue", "3") + .param("pointValue", "1")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("levelValue 가 숫자가 아니면 400으로 거절한다") + void rejectsNonNumericLevelValue() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}/level", target.getId()) + .param("levelType", "attendance") + .param("levelValue", "abc") + .param("pointValue", "1")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("필수 파라미터가 빠지면 400으로 거절한다") + void rejectsMissingParameters() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(post("/admin/user/{userId}/level", target.getId()) + .param("levelType", "attendance")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("사용자 삭제") + class DeleteUser { + + @Test + @DisplayName("사용자를 삭제하면 200을 주고 조회되지 않는다") + void deletesUser() throws Exception { + User target = fixtures.createUser(); + + mockMvc.perform(delete("/admin/user/{userId}", target.getId())) + .andExpect(status().isOk()); + + assertThat(userRepository.findById(target.getId())) + .as("소프트 삭제 후에도 조회되면 탈퇴가 동작하지 않은 것이다") + .isEmpty(); + } + + @Test + @DisplayName("삭제해도 다른 사용자는 남는다") + void keepsOtherUsers() throws Exception { + User target = fixtures.createUser(); + User other = fixtures.createOtherUser(); + + mockMvc.perform(delete("/admin/user/{userId}", target.getId())) + .andExpect(status().isOk()); + + assertThat(userRepository.findById(other.getId())).isPresent(); + } + + @Test + @DisplayName("없는 사용자를 삭제하면 404로 응답한다") + void returnsNotFoundForUnknownUser() throws Exception { + mockMvc.perform(delete("/admin/user/{userId}", 999_999L)) + .andExpect(status().isNotFound()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java b/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java new file mode 100644 index 00000000..6d632889 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/security/AdminEndpointAuthorizationTest.java @@ -0,0 +1,171 @@ +package com.aisip.OnO.backend.admin.security; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; + +/** + * 관리자 화면 전체에 대한 접근 통제 검증. + * + *

이 프로젝트에서 가장 위험한 지점이다. {@code /admin/**} 아래 한 엔드포인트만 뚫려도 + * 전체 사용자 목록·오답 문제 원문·복습노트·피드백이 그대로 노출되고, + * {@code DELETE /admin/user/{id}} 는 남의 계정을 지울 수 있다. + * + *

따라서 "관리자 화면은 ROLE_ADMIN 만" 이라는 규칙을 컨트롤러 하나씩이 아니라 + * 엔드포인트 목록 전체에 대해 확인한다. 새 관리자 엔드포인트가 추가됐는데 + * 이 목록에 들어오지 않으면 {@link #everyAdminMappingIsCoveredByThisTest()} 가 실패한다. + */ +@DisplayName("관리자 엔드포인트 접근 통제") +class AdminEndpointAuthorizationTest extends AdminTestSupport { + + private User member; + + @BeforeEach + void setUpMember() { + member = fixtures.createUser(); + } + + /** + * 관리자 컨트롤러 7개의 모든 엔드포인트. + * + *

경로 변수에는 존재하지 않는 id를 넣는다. 권한 검사는 컨트롤러에 진입하기 전에 + * 끝나야 하므로, 데이터가 없어도 결과가 달라지면 안 된다. + */ + static Stream adminEndpoints() { + return Stream.of( + // AdminController + new Object[]{HttpMethod.GET, "/admin/main"}, + new Object[]{HttpMethod.GET, "/admin/user/image/view?url=https://example.com/a.png"}, + // AdminUserController + new Object[]{HttpMethod.GET, "/admin/users"}, + new Object[]{HttpMethod.GET, "/admin/user/999999"}, + new Object[]{HttpMethod.POST, "/admin/user/999999"}, + new Object[]{HttpMethod.POST, "/admin/user/999999/level?levelType=attendance&levelValue=3&pointValue=5"}, + new Object[]{HttpMethod.DELETE, "/admin/user/999999"}, + // AdminProblemController + new Object[]{HttpMethod.GET, "/admin/problems"}, + new Object[]{HttpMethod.GET, "/admin/problem/999999"}, + // AdminPracticeNoteController + new Object[]{HttpMethod.GET, "/admin/practice-notes"}, + new Object[]{HttpMethod.GET, "/admin/practice-logs"}, + // AdminStudyRoomController + new Object[]{HttpMethod.GET, "/admin/study-rooms"}, + new Object[]{HttpMethod.GET, "/admin/study-rooms/999999"}, + // AdminAnalysisController + new Object[]{HttpMethod.GET, "/admin/analysis"}, + new Object[]{HttpMethod.GET, "/admin/analysis/daily-new-users?date=2026-01-01"}, + new Object[]{HttpMethod.GET, "/admin/analysis/daily-active-users?date=2026-01-01"}, + // AdminFeedbackController + new Object[]{HttpMethod.GET, "/admin/feedbacks"}, + new Object[]{HttpMethod.GET, "/admin/feedbacks/999999"} + ); + } + + private MockHttpServletRequestBuilder call(HttpMethod method, String uri) { + return request(method, uri); + } + + @Nested + @DisplayName("일반 사용자") + class AsMember { + + @ParameterizedTest(name = "{0} {1} 는 ROLE_MEMBER 에게 403") + @MethodSource("com.aisip.OnO.backend.admin.security.AdminEndpointAuthorizationTest#adminEndpoints") + @DisplayName("ROLE_MEMBER 로 관리자 엔드포인트를 호출하면 403 이고 본문이 새어 나가지 않는다") + void memberIsForbidden(HttpMethod method, String uri) throws Exception { + authenticateAs(member.getId()); + + MvcResult result = mockMvc.perform(call(method, uri)).andReturn(); + + assertThat(result.getResponse().getStatus()) + .as("%s %s 가 일반 사용자에게 뚫리면 전체 사용자 데이터가 노출된다", method, uri) + .isEqualTo(403); + assertThat(result.getResponse().getContentAsString()) + .as("차단됐다면 관리자 화면(HTML)이 렌더링돼서는 안 된다") + .doesNotContain(" java.util.Arrays.stream(type.getDeclaredMethods())) + .filter(m -> java.util.Arrays.stream(m.getAnnotations()) + .anyMatch(a -> a.annotationType().getSimpleName().endsWith("Mapping"))) + .count(); + + assertThat(adminEndpoints().count()) + .as("관리자 엔드포인트를 추가했다면 adminEndpoints() 에도 반드시 넣어 권한 검증을 받게 해야 한다") + .isEqualTo(declaredHandlerCount); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/security/AdminFormLoginTest.java b/src/test/java/com/aisip/OnO/backend/admin/security/AdminFormLoginTest.java new file mode 100644 index 00000000..c5143e5d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/security/AdminFormLoginTest.java @@ -0,0 +1,183 @@ +package com.aisip.OnO.backend.admin.security; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 관리자 폼 로그인({@code admin.identifier} / {@code admin.password} 설정 기반) 검증. + * + *

이 경로가 뚫리면 관리자 화면 전체가 열린다. 성공 경로 하나보다 + * 실패해야 하는 입력이 전부 실패하는지가 중요하다. + */ +@DisplayName("관리자 폼 로그인") +class AdminFormLoginTest extends AdminTestSupport { + + private static final String LOGIN_URL = "/perform_login"; + private static final String SUCCESS_URL = "http://localhost:8080/admin/main"; + private static final String FAILURE_URL = "http://localhost:8080/login?error"; + + @BeforeEach + void clearContext() { + clearAuthentication(); + } + + private MvcResult login(String username, String password) throws Exception { + return mockMvc.perform(post(LOGIN_URL) + .param("username", username) + .param("password", password)) + .andReturn(); + } + + @Nested + @DisplayName("성공") + class Success { + + @Test + @DisplayName("설정된 관리자 자격증명으로 로그인하면 관리자 메인으로 보내고 ROLE_ADMIN 토큰을 발급한다") + void issuesAdminTokenOnValidCredentials() throws Exception { + User admin = createAdminUser(); + + MvcResult result = login(adminIdentifier, adminPassword); + + assertThat(result.getResponse().getStatus()).isEqualTo(302); + assertThat(result.getResponse().getRedirectedUrl()).isEqualTo(SUCCESS_URL); + assertThat(result.getResponse().getHeader("Authorization")) + .as("성공 핸들러가 액세스 토큰을 헤더로 내려줘야 관리자 화면이 API 를 호출할 수 있다") + .isNotNull() + .startsWith("Bearer "); + assertThat(admin.getId()).isNotNull(); + } + + /** + * 로그인 성공 헤더가 그대로 인증에 쓰일 수 있어야 한다. + * + *

{@code JwtTokenizer.createAccessToken} 은 이미 {@code "Bearer "} 접두사를 붙여 돌려주는데 + * {@code SecurityConfig} 의 로그인 성공 핸들러가 한 번 더 붙여 {@code "Bearer Bearer eyJ..."} 를 + * 내려주고 있었다. {@code JwtTokenFilter} 는 앞 7글자만 떼므로 파싱에 실패해 401 이 됐고, + * 결국 로그인 성공 응답의 헤더를 그대로 쓰면 인증되지 않았다. + * 접두사를 프론트에 내려주는 토큰 형식이 이미 계약이라 createAccessToken 은 그대로 두고 + * 중복해서 붙이던 쪽을 고쳤다. + */ + @Test + @DisplayName("성공 응답의 Authorization 헤더에 Bearer 가 한 번만 붙는다") + void authorizationHeaderIsPrefixedOnce() throws Exception { + createAdminUser(); + + String header = login(adminIdentifier, adminPassword).getResponse().getHeader("Authorization"); + + assertThat(header) + .as("접두사가 두 번 붙으면 필터가 토큰을 파싱하지 못한다") + .startsWith("Bearer ") + .doesNotStartWith("Bearer Bearer "); + } + + @Test + @DisplayName("로그인 성공 헤더를 그대로 쓰면 관리자 권한으로 동작한다") + void issuedTokenOpensAdminPage() throws Exception { + createAdminUser(); + + String usableToken = login(adminIdentifier, adminPassword).getResponse().getHeader("Authorization"); + + mockMvc.perform(post("/admin/user/999999/level") + .header("Authorization", usableToken) + .param("levelType", "attendance") + .param("levelValue", "1") + .param("pointValue", "0")) + // 404 = 권한 검사를 통과해 컨트롤러까지 들어갔고, 없는 사용자라 거절된 것. + // 토큰 내용은 ROLE_ADMIN 으로 올바르게 만들어진다. 문제는 접두사뿐이다. + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("실패") + class Failure { + + @Test + @DisplayName("비밀번호가 틀리면 로그인 실패 페이지로 되돌린다") + void rejectsWrongPassword() throws Exception { + createAdminUser(); + + MvcResult result = login(adminIdentifier, adminPassword + "-틀림"); + + assertThat(result.getResponse().getRedirectedUrl()).isEqualTo(FAILURE_URL); + assertThat(result.getResponse().getHeader("Authorization")) + .as("실패했는데 토큰이 나가면 그대로 관리자 권한이 넘어간다") + .isNull(); + } + + @Test + @DisplayName("존재하지 않는 관리자 계정은 로그인 실패 페이지로 되돌린다") + void rejectsUnknownIdentifier() throws Exception { + MvcResult result = login("없는-관리자", adminPassword); + + assertThat(result.getResponse().getRedirectedUrl()).isEqualTo(FAILURE_URL); + assertThat(result.getResponse().getHeader("Authorization")).isNull(); + } + + @Test + @DisplayName("빈 아이디와 빈 비밀번호는 로그인 실패로 처리한다") + void rejectsEmptyCredentials() throws Exception { + createAdminUser(); + + MvcResult result = login("", ""); + + assertThat(result.getResponse().getRedirectedUrl()).isEqualTo(FAILURE_URL); + assertThat(result.getResponse().getHeader("Authorization")).isNull(); + } + + @Test + @DisplayName("비밀번호만 비우면 로그인 실패로 처리한다") + void rejectsEmptyPassword() throws Exception { + createAdminUser(); + + MvcResult result = login(adminIdentifier, ""); + + assertThat(result.getResponse().getRedirectedUrl()).isEqualTo(FAILURE_URL); + } + + @Test + @DisplayName("일반 소셜 로그인 사용자의 identifier 로는 관리자 로그인을 할 수 없다") + void rejectsSocialLoginUser() throws Exception { + User member = fixtures.createUser(); + + MvcResult result = login(member.getIdentifier(), "아무비밀번호"); + + assertThat(result.getResponse().getRedirectedUrl()) + .as("비밀번호가 없는 사용자가 관리자로 로그인되면 전체 데이터가 열린다") + .isEqualTo(FAILURE_URL); + assertThat(result.getResponse().getHeader("Authorization")).isNull(); + } + + @Test + @DisplayName("로그인에 실패하면 관리자 화면 접근도 계속 막힌다") + void keepsAdminPageClosedAfterFailedLogin() throws Exception { + createAdminUser(); + login(adminIdentifier, "틀린비밀번호"); + + mockMvc.perform(post("/admin/user/1/level") + .param("levelType", "attendance") + .param("levelValue", "1") + .param("pointValue", "0")) + .andExpect(status().isUnauthorized()); + } + } + + @Test + @DisplayName("로그인 페이지 자체는 인증 없이 열린다") + void loginPageIsPublic() throws Exception { + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/login")) + .andExpect(status().isOk()) + .andExpect(redirectedUrl(null)); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/service/AdminServiceTest.java b/src/test/java/com/aisip/OnO/backend/admin/service/AdminServiceTest.java new file mode 100644 index 00000000..82d5d963 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/service/AdminServiceTest.java @@ -0,0 +1,93 @@ +package com.aisip.OnO.backend.admin.service; + +import com.aisip.OnO.backend.admin.support.AdminTestSupport; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("AdminService") +class AdminServiceTest extends AdminTestSupport { + + @Autowired + private AdminService adminService; + + @Autowired + private PasswordEncoder encoder; + + @Nested + @DisplayName("관리자 조회") + class LoadUserByUsername { + + @Test + @DisplayName("설정된 관리자 identifier 로 조회하면 ROLE_ADMIN 권한과 userId 를 담아 돌려준다") + void returnsAdminDetailsWithAdminRole() { + User admin = createAdminUser(); + + UserDetails details = adminService.loadUserByUsername(adminIdentifier); + + assertThat(details).isInstanceOf(CustomAdminService.class); + assertThat(((CustomAdminService) details).getUserId()).isEqualTo(admin.getId()); + assertThat(details.getUsername()).isEqualTo(adminIdentifier); + assertThat(details.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .as("여기서 ROLE_ADMIN 이 빠지면 /admin/** 전체가 잠긴다") + .containsExactly("ROLE_ADMIN"); + assertThat(encoder.matches(adminPassword, details.getPassword())) + .as("암호화된 비밀번호를 그대로 전달해야 폼 로그인이 검증할 수 있다") + .isTrue(); + } + + @Test + @DisplayName("계정 상태 플래그는 모두 사용 가능으로 열려 있다") + void marksAccountAsUsable() { + createAdminUser(); + + UserDetails details = adminService.loadUserByUsername(adminIdentifier); + + assertThat(details.isEnabled()).isTrue(); + assertThat(details.isAccountNonExpired()).isTrue(); + assertThat(details.isAccountNonLocked()).isTrue(); + assertThat(details.isCredentialsNonExpired()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 identifier 는 USER_NOT_FOUND 로 거절한다") + void rejectsUnknownIdentifier() { + assertThatThrownBy(() -> adminService.loadUserByUsername("존재하지-않는-관리자")) + .isInstanceOf(ApplicationException.class) + .extracting(thrown -> ((ApplicationException) thrown).getErrorCase()) + .isEqualTo(UserErrorCase.USER_NOT_FOUND); + } + + @Test + @DisplayName("일반 사용자의 identifier 로도 조회되면 그 사용자에게 ROLE_ADMIN 이 붙는다는 사실을 고정해 둔다") + void grantsAdminRoleToAnyIdentifierItResolves() { + User member = fixtures.createUser(); + String memberIdentifier = member.getIdentifier(); + + UserDetails details = adminService.loadUserByUsername(memberIdentifier); + + assertThat(details.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .as(""" + AdminService 는 identifier 로 찾은 사용자에게 무조건 ROLE_ADMIN 을 부여한다. + 즉 관리자 여부를 가르는 유일한 방어선은 '비밀번호를 아는가' 하나뿐이다. + 일반 사용자는 password 가 null 이라 폼 로그인은 통과하지 못하지만, + 이 특성을 바꿀 때는 반드시 이 테스트를 다시 보게 만든다.""") + .containsExactly("ROLE_ADMIN"); + assertThat(details.getPassword()) + .as("소셜 로그인 사용자는 비밀번호가 없어 폼 로그인으로는 인증될 수 없다") + .isNull(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/admin/support/AdminTestSupport.java b/src/test/java/com/aisip/OnO/backend/admin/support/AdminTestSupport.java new file mode 100644 index 00000000..fa1b9643 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/admin/support/AdminTestSupport.java @@ -0,0 +1,137 @@ +package com.aisip.OnO.backend.admin.support; + +import com.aisip.OnO.backend.feedback.entity.UserFeedback; +import com.aisip.OnO.backend.feedback.repository.UserFeedbackRepository; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * admin 도메인 테스트의 공통 베이스. + * + *

{@link IntegrationTestSupport} 의 애노테이션 조합을 그대로 물려받는다. + * {@code @MockBean} 을 새로 선언하면 스프링 컨텍스트가 갈라지므로 추가하지 않는다. + * + *

관리자 계정은 {@code AdminConfig} 의 {@code CommandLineRunner} 가 부팅 시 한 번 만들지만, + * {@code DatabaseCleaner} 가 매 테스트 전에 user 테이블까지 비우기 때문에 테스트 시점에는 남아 있지 않다. + * 관리자 계정이 필요한 테스트는 {@link #createAdminUser()} 로 직접 만들어야 한다. + */ +public abstract class AdminTestSupport extends IntegrationTestSupport { + + @Autowired + protected UserRepository userRepository; + + @Autowired + protected ProblemRepository problemRepository; + + @Autowired + protected PracticeNoteRepository practiceNoteRepository; + + @Autowired + protected MissionLogRepository missionLogRepository; + + @Autowired + protected UserFeedbackRepository userFeedbackRepository; + + @Autowired + protected StudyRoomRepository studyRoomRepository; + + @Autowired + protected StudyRoomMemberRepository studyRoomMemberRepository; + + @Autowired + protected PasswordEncoder passwordEncoder; + + @Autowired + protected JdbcTemplate jdbcTemplate; + + @Value("${admin.identifier}") + protected String adminIdentifier; + + @Value("${admin.password}") + protected String adminPassword; + + /** 설정에 적힌 관리자 자격증명으로 관리자 계정을 만든다. AdminConfig 가 부팅 시 만드는 것과 같은 형태다. */ + protected User createAdminUser() { + return userRepository.save(User.from(new UserRegisterDto( + "admin@ono.com", + "Admin", + adminIdentifier, + "ADMIN", + passwordEncoder.encode(adminPassword) + ))); + } + + protected Problem saveProblem(Long userId, Folder folder, String memo) { + Problem problem = Problem.from( + new ProblemRegisterDto(null, memo, null, folder == null ? null : folder.getId(), null), + userId + ); + if (folder != null) { + problem.updateFolder(folder); + } + return problemRepository.save(problem); + } + + protected PracticeNote savePracticeNote(Long userId, String title) { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, title, List.of(), null), + userId + )); + } + + protected MissionLog saveMissionLog(User user, MissionType missionType, Long referenceId) { + return missionLogRepository.save(MissionLog.from( + com.aisip.OnO.backend.mission.dto.MissionRegisterDto.builder() + .userId(user.getId()) + .missionType(missionType) + .referenceId(referenceId) + .build(), + user + )); + } + + protected StudyRoom saveStudyRoom(String name, User host) { + StudyRoom room = studyRoomRepository.save(StudyRoom.create(name, host.getId())); + StudyRoomMember member = StudyRoomMember.create(host, StudyRoomMemberRole.HOST); + member.updateRoom(room); + studyRoomMemberRepository.save(member); + return room; + } + + protected UserFeedback saveFeedback(int npsScore, String usagePurpose) { + return userFeedbackRepository.save(UserFeedback.builder() + .npsScore(npsScore) + .usagePurpose(usagePurpose) + .submittedAt(LocalDateTime.now()) + .build()); + } + + /** created_at 은 BaseEntity 가 자동으로 채우므로, 과거 날짜가 필요하면 직접 밀어 넣는다. */ + protected void forceCreatedAt(String table, Long id, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE `" + table + "` SET created_at = ? WHERE id = ?", createdAt, id); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java new file mode 100644 index 00000000..7743d69e --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java @@ -0,0 +1,207 @@ +package com.aisip.OnO.backend.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaField; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Transient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Set; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * 반복적으로 프로덕션 장애를 만들어 온 실수를 구조적으로 막는다. + * + *

여기 있는 규칙은 전부 이 서비스에서 실제로 장애를 낸 결함에서 나왔다. + * 개별 건을 고치는 것으로 끝내면 같은 형태가 다시 들어온다. 규칙으로 올려 두면 + * 그 결함 자체가 다시 들어올 수 없다. + * + *

이미 존재하는 위반 중 지금 고칠 수 없는 것은 목록으로 고정해 둔다. + * 새로 들어오는 코드만 막고, 목록은 줄여 나간다. + */ +@DisplayName("아키텍처 규칙") +class PersistenceRulesTest { + + private static JavaClasses productionClasses; + + @BeforeAll + static void importClasses() { + productionClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + // QueryDSL 이 만들어 낸 Q 클래스는 사람이 손대는 코드가 아니다. + .withImportOption(location -> !location.contains("/Q")) + .importPackages("com.aisip.OnO.backend"); + } + + @Nested + @DisplayName("트랜잭션 경계") + class TransactionBoundary { + + /** + * Spring AOP 는 프록시 기반이라 같은 클래스 안에서 호출되는 private 메서드를 가로챌 수 없다. + * 즉 private 메서드의 {@code @Transactional} 은 아무 일도 하지 않는다. + * + *

실제로 ProblemService 의 analysisProblemWithoutOwnerCheck, deleteProblemWithoutOwnerCheck, + * deleteFolderProblems 와 FolderService 의 findFolderEntity 가 이 상태였다. 작성자는 + * 트랜잭션 경계가 있다고 믿었지만 실제로는 없었고, 그 사실을 알려 주는 것은 아무것도 없었다. + */ + @Test + @DisplayName("@Transactional 은 public 메서드에만 붙는다 - 프록시가 가로채지 못하면 무효다") + void transactionalOnlyOnPublicMethods() { + methods() + .that().areAnnotatedWith(Transactional.class) + .should().bePublic() + .because("Spring AOP 프록시는 private·protected 메서드를 가로챌 수 없어 " + + "@Transactional 이 조용히 무효가 된다") + .check(productionClasses); + } + } + + @Nested + @DisplayName("엔티티 매핑") + class EntityMapping { + + /** + * {@code @Enumerated} 를 빼면 JPA 기본값은 ORDINAL 이라 enum 의 선언 순서가 DB 에 저장된다. + * 나중에 상수를 중간에 끼워 넣거나 순서를 바꾸면 이미 저장된 모든 행의 의미가 조용히 뒤바뀐다. + */ + private static final Set ENUMERATED_EXCEPTIONS = Set.of( + // 이미 ORDINAL 로 저장된 데이터가 쌓여 있다. STRING 으로 바꾸려면 데이터 마이그레이션이 + // 선행돼야 해서 이번 범위에서 다루지 못했다. 현재 선언 순서는 + // UserMissionStatusTest.pinsOrdinalOrder() 가 고정하고 있다. + "com.aisip.OnO.backend.mission.entity.MissionLog.missionType" + ); + + @Test + @DisplayName("엔티티의 enum 필드는 @Enumerated 를 붙인다 - 순서가 아니라 이름으로 저장하도록") + void enumFieldsDeclareEnumerated() { + fields() + .that().areDeclaredInClassesThat().areAnnotatedWith(Entity.class) + .and().haveRawType(enumType()) + .and(persistentField()) + .and(not(knownException())) + .should().beAnnotatedWith(Enumerated.class) + .because("생략하면 ORDINAL 로 저장돼 enum 선언 순서를 바꾸는 순간 기존 데이터의 의미가 바뀐다") + .check(productionClasses); + } + + private DescribedPredicate knownException() { + return new DescribedPredicate<>("이미 알려진 예외") { + @Override + public boolean test(JavaField field) { + return ENUMERATED_EXCEPTIONS.contains(field.getFullName().replaceAll("\\.\\w+$", "." + field.getName())); + } + }; + } + } + + @Nested + @DisplayName("시간대") + class TimeZone { + + /** + * {@code LocalDate.now()} 는 JVM 기본 시간대를 쓴다. 서비스는 한국 사용자 기준으로 하루를 세므로 + * 서버 시간대가 KST 가 아니면 "오늘"이 어긋난다. + * + *

실제로 mission 도메인은 JVM 기본 시간대를, problem·studyroom 은 Asia/Seoul 을 쓰고 있었고, + * LearningReportService 는 같은 클래스 안에서도 메서드마다 기준이 갈라져 있었다. + * 그 결과 자정 부근 기록이 도메인마다 다른 날짜로 집계됐다. + */ + /** + * 이미 존재하는 위반 목록. 새 코드만 막고 이 목록은 줄여 나간다. + * + *

프로덕션 컨테이너는 {@code TZ: Asia/Seoul} 과 {@code -Duser.timezone=Asia/Seoul} 로 뜨기 때문에 + * 지금 당장은 인자 없는 {@code now()} 도 KST 를 낸다. 즉 이 목록은 현재 장애 원인이 아니라 + * 잠재 위험이다. 서버 시간대 설정이 빠지거나 다른 환경에서 돌리는 순간 하루 경계가 어긋난다. + * 한 번에 24곳을 바꾸는 것은 이 PR 의 범위(테스트 개선) 대비 위험이 커서 고정만 해 둔다. + */ + private static final Set ZONE_LESS_NOW_EXCEPTIONS = Set.of( + "com.aisip.OnO.backend.admin.controller.AdminAnalysisController", + "com.aisip.OnO.backend.feedback.service.FeedbackService", + "com.aisip.OnO.backend.mission.repository.MissionLogRepositoryImpl", + "com.aisip.OnO.backend.practicenote.entity.PracticeNote", + "com.aisip.OnO.backend.studyroom.service.StudyRoomChallengeService", + "com.aisip.OnO.backend.studyroom.service.StudyRoomInviteService", + "com.aisip.OnO.backend.studyroom.service.StudyRoomWeeklyReportService", + "com.aisip.OnO.backend.user.service.UserService", + "com.aisip.OnO.backend.util.fileupload.service.FileUploadService" + ); + + @Test + @DisplayName("현재 시각은 시간대를 명시해서 읽는다 - 서버 시간대에 따라 하루가 밀리지 않도록") + void currentTimeIsReadWithExplicitZone() { + noClasses() + .that(not(alreadyKnownToUseZonelessNow())) + .should().callMethod(LocalDate.class, "now") + .orShould().callMethod(LocalDateTime.class, "now") + .because("인자 없는 now() 는 JVM 기본 시간대를 쓴다. " + + "서비스 기준은 Asia/Seoul 이므로 now(ZoneId) 로 명시해야 한다") + .check(productionClasses); + } + + @Test + @DisplayName("기본 시간대를 끌어다 쓰지 않는다 - 하루 경계는 항상 명시된 시간대로") + void doesNotFallBackToJvmDefaultZone() { + // now(ZoneId.systemDefault()) 는 인자 없는 now() 와 똑같이 JVM 기본 시간대를 쓰면서 + // 위 규칙만 피해 간다. 게다가 한 번 static final 로 잡아 두면 동작으로는 구별할 수 없어 + // 테스트로 잡을 방법이 없다. 그래서 호출 자체를 여기서 막는다. + noClasses() + .should().callMethod(ZoneId.class, "systemDefault") + .because("서비스 기준 시간대는 Asia/Seoul 이다. " + + "JVM 기본값을 끌어다 쓰면 배포 환경 설정이 빠지는 순간 하루 경계가 어긋난다") + .check(productionClasses); + } + + private DescribedPredicate alreadyKnownToUseZonelessNow() { + return new DescribedPredicate<>("이미 알려진 위반") { + @Override + public boolean test(com.tngtech.archunit.core.domain.JavaClass javaClass) { + return ZONE_LESS_NOW_EXCEPTIONS.contains(javaClass.getName()); + } + }; + } + } + + // ─────────────────────────── 조건 정의 ─────────────────────────── + + private static DescribedPredicate enumType() { + return new DescribedPredicate<>("enum 타입") { + @Override + public boolean test(com.tngtech.archunit.core.domain.JavaClass javaClass) { + return javaClass.isEnum(); + } + }; + } + + /** 컬럼으로 매핑되지 않는 필드는 규칙 대상이 아니다. */ + private static DescribedPredicate persistentField() { + return new DescribedPredicate<>("영속 대상 필드") { + @Override + public boolean test(JavaField field) { + return !field.isAnnotatedWith(Transient.class) + && !field.isAnnotatedWith(Id.class) + && !field.getModifiers().contains(JavaModifier.STATIC); + } + }; + } + + private static DescribedPredicate not(DescribedPredicate predicate) { + return DescribedPredicate.not(predicate); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java new file mode 100644 index 00000000..20bf7601 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java @@ -0,0 +1,584 @@ +package com.aisip.OnO.backend.auth.controller; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.auth.service.JwtTokenizer; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 인증 API 의 HTTP 계약을 끝에서 끝까지 확인한다. + * + *

여기서 검증하는 것은 상태코드와 errorCode 조합이다. + * 프론트(HttpService)는 errorCode 1005 또는 errorCode 없는 401 일 때만 토큰 갱신을 시도하고, + * 1001·1002·1003·1004·1006 은 재로그인으로 처리한다. 이 대응이 어긋나면 + * 정상 사용자가 갱신 기회를 잃고 로그아웃당한다. + */ +@DisplayName("인증 API") +class AuthApiTest extends IntegrationTestSupport { + + private static final String PROTECTED_API = "/api/users"; + private static final String OTHER_SECRET = + "b3RoZXItc2VjcmV0LWtleS1vdGhlci1zZWNyZXQta2V5LW90aGVyLXNlY3JldC0zMmJ5dGVz"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.accessToken.secret}") + private String accessSecret; + + @Value("${jwt.refreshToken.secret}") + private String refreshSecret; + + /** 이미 만료된 토큰만 발급한다. 서명 키는 애플리케이션과 동일하다. */ + private JwtTokenizer expiredTokenizer; + + /** 서명은 유효하지만 DB 에 세션이 없는 토큰을 만들 때 쓴다. */ + private JwtTokenizer validTokenizer; + + @BeforeEach + void setUpTokenizers() { + expiredTokenizer = new JwtTokenizer(-60_000L, -60_000L, accessSecret, refreshSecret); + validTokenizer = new JwtTokenizer(1_800_000L, 604_800_000L, accessSecret, refreshSecret); + } + + private JsonNode dataOf(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); + } + + private JsonNode signUpGuest() throws Exception { + return dataOf(mockMvc.perform(post("/api/auth/signup/guest")) + .andExpect(status().isOk()) + .andReturn()); + } + + private JsonNode signUpMember(String identifier, String platform) throws Exception { + return dataOf(mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .identifier(identifier) + .platform(platform) + .name("소셜사용자") + .email(identifier + "@test.ono") + .build()))) + .andExpect(status().isOk()) + .andReturn()); + } + + private ResultActions callProtectedApi(String authorizationHeader) throws Exception { + return authorizationHeader == null + ? mockMvc.perform(get(PROTECTED_API)) + : mockMvc.perform(get(PROTECTED_API).header("Authorization", authorizationHeader)); + } + + private ResultActions refresh(String refreshToken) throws Exception { + return mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequestDto(null, refreshToken)))); + } + + @Nested + @DisplayName("가입과 로그인") + class SignUp { + + @Test + @DisplayName("게스트 가입은 사용자와 리프레시 토큰 세션을 함께 만든다") + void guestSignUpCreatesUserAndSession() throws Exception { + JsonNode tokens = signUpGuest(); + + assertThat(tokens.path("accessToken").asText()).startsWith("Bearer "); + assertThat(tokens.path("refreshToken").asText()).isNotBlank(); + assertThat(userRepository.count()).isEqualTo(1); + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("세션이 저장되지 않으면 첫 갱신부터 1002 가 난다") + .isPresent(); + } + + @Test + @DisplayName("게스트 토큰에는 ROLE_GUEST 권한이 담긴다") + void guestTokenCarriesGuestAuthority() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + assertThat(validTokenizer.getAuthorityFromAccessToken(accessToken.replace("Bearer ", ""))) + .isEqualTo(Authority.ROLE_GUEST); + } + + @ParameterizedTest(name = "{0} 로그인으로 가입하면 멤버 토큰을 받는다") + @ValueSource(strings = {"GOOGLE", "APPLE"}) + void memberSignUpPerPlatform(String platform) throws Exception { + JsonNode tokens = signUpMember(platform.toLowerCase() + "-sub-1", platform); + + assertThat(validTokenizer.getAuthorityFromAccessToken( + tokens.path("accessToken").asText().replace("Bearer ", ""))) + .isEqualTo(Authority.ROLE_MEMBER); + assertThat(userRepository.findByIdentifier(platform.toLowerCase() + "-sub-1")) + .isPresent() + .get() + .satisfies(user -> assertThat(user.getPlatform()).isEqualTo(platform)); + } + + @Test + @DisplayName("같은 소셜 계정으로 다시 로그인해도 계정이 새로 생기지 않는다") + void reloginDoesNotDuplicateAccount() throws Exception { + JsonNode first = signUpMember("google-sub-repeat", "GOOGLE"); + JsonNode second = signUpMember("google-sub-repeat", "GOOGLE"); + + assertThat(userRepository.count()) + .as("같은 identifier 로 계정이 갈리면 사용자는 자기 오답노트를 잃는다") + .isEqualTo(1); + assertThat(second.path("accessToken").asText()).isNotBlank(); + assertThat(validTokenizer.getUserIdFromAccessToken( + second.path("accessToken").asText().replace("Bearer ", ""))) + .isEqualTo(validTokenizer.getUserIdFromAccessToken( + first.path("accessToken").asText().replace("Bearer ", ""))); + } + + @Test + @DisplayName("로그인할 때마다 기기별 리프레시 토큰 세션이 따로 쌓인다") + void keepsSessionPerLogin() throws Exception { + JsonNode phone = signUpMember("google-sub-multi", "GOOGLE"); + JsonNode tablet = signUpMember("google-sub-multi", "GOOGLE"); + + assertThat(refreshTokenRepository.findByRefreshToken(phone.path("refreshToken").asText())) + .as("나중 로그인이 앞선 기기의 세션을 지우면 그 기기는 1002 로 튕긴다") + .isPresent(); + assertThat(refreshTokenRepository.findByRefreshToken(tablet.path("refreshToken").asText())).isPresent(); + } + + @Test + @DisplayName("본문이 없는 가입 요청은 400 으로 거절한다") + void rejectsEmptyBody() throws Exception { + mockMvc.perform(post("/api/auth/signup/member").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + /** + * 프로덕션에서 관측된 "소셜 로그인 실패. 잘못된 유저 정보입니다" 계열 요청. + * identifier 없이 가입시키면 다음 로그인에서 같은 계정을 찾지 못해 계정이 계속 늘어난다. + */ + @Test + @DisplayName("identifier 없는 소셜 로그인은 계정을 만들지 않고 400 + 3002 로 거절한다") + void rejectsSignUpWithoutIdentifier() throws Exception { + mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .platform("GOOGLE") + .name("소셜사용자") + .email("no-identifier@test.ono") + .build()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(UserErrorCase.INVALID_USER_IDENTIFIER.getErrorCode())); + + assertThat(userRepository.count()) + .as("잘못된 요청으로 유령 계정이 생기면 안 된다") + .isZero(); + } + + @Test + @DisplayName("빈 identifier 로 두 번 요청해도 계정이 쌓이지 않는다") + void rejectsBlankIdentifierRepeatedly() throws Exception { + for (int i = 0; i < 2; i++) { + mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .identifier(" ") + .platform("APPLE") + .name("소셜사용자") + .build()))) + .andExpect(status().isBadRequest()); + } + + assertThat(userRepository.count()).isZero(); + } + } + + @Nested + @DisplayName("액세스 토큰 검증 계약") + class AccessTokenContract { + + @Test + @DisplayName("정상 토큰은 보호된 API 를 통과한다") + void allowsValidToken() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + callProtectedApi(accessToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").isNumber()); + } + + /** + * 프론트는 1005 를 받아야 refreshAccessToken() 을 호출한다. + * 여기가 1007 로 나가면 만료된 사용자는 갱신 없이 인증 실패 처리된다. + */ + @Test + @DisplayName("만료된 액세스 토큰은 401 + 1005 로 응답해 프론트가 갱신하게 한다") + void expiredAccessTokenReturnsAccessTokenExpired() throws Exception { + String expired = expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + callProtectedApi(expired) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getMessage())); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 401 + 1007 로 응답한다") + void missingHeaderReturnsAuthenticationFailed() throws Exception { + callProtectedApi(null) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("Bearer 프리픽스 없이 토큰만 보내면 401 + 1007 로 응답한다") + void missingBearerPrefixReturnsAuthenticationFailed() throws Exception { + String rawToken = signUpGuest().path("accessToken").asText().replace("Bearer ", ""); + + callProtectedApi(rawToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + /** + * Bearer 로 시작하면 토큰 파싱까지 가므로 "유효하지 않은 토큰"(1009)이고, + * 프리픽스 자체가 없으면 토큰을 꺼내지도 못해 진입점 기본값(1007)이다. + * 둘 다 1000~1999 라 앱 동작(강제 로그아웃)은 같다. + */ + @ParameterizedTest(name = "형식이 깨진 Bearer 헤더 [{0}] 는 401 + 1009 로 응답한다") + @ValueSource(strings = {"Bearer ", "Bearer not-a-jwt", "Bearer a.b.c"}) + void malformedBearerReturnsInvalidAccessToken(String header) throws Exception { + callProtectedApi(header) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("Bearer 프리픽스가 아예 없는 헤더는 401 + 1007 로 응답한다") + void nonBearerHeaderReturnsAuthenticationFailed() throws Exception { + callProtectedApi("Basic dXNlcjpwYXNz") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("다른 키로 서명한 위조 토큰은 401 + 1009 로 거절한다") + void forgedTokenReturnsAuthenticationFailed() throws Exception { + String forged = "Bearer " + Jwts.builder() + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setSubject("1") + .setExpiration(new Date(System.currentTimeMillis() + 600_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + callProtectedApi(forged) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("리프레시 토큰으로는 보호된 API 를 호출할 수 없다 (401 + 1009)") + void refreshTokenCannotAccessApi() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + callProtectedApi("Bearer " + refreshToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("서명은 맞지만 존재하지 않는 사용자의 토큰은 인증을 통과한 뒤 사용자 조회에서 걸린다") + void tokenOfDeletedUserFailsAtLookup() throws Exception { + String orphanToken = validTokenizer.createAccessToken("999999", Map.of("authority", Authority.ROLE_MEMBER)); + + callProtectedApi(orphanToken) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").isNumber()); + } + } + + @Nested + @DisplayName("토큰 갱신") + class RefreshToken { + + @Test + @DisplayName("리프레시 토큰으로 액세스·리프레시 토큰을 함께 재발급한다") + void reissuesBothTokens() throws Exception { + JsonNode issued = signUpGuest(); + String oldRefreshToken = issued.path("refreshToken").asText(); + + JsonNode refreshed = dataOf(refresh(oldRefreshToken).andExpect(status().isOk()).andReturn()); + + assertThat(refreshed.path("accessToken").asText()).startsWith("Bearer "); + assertThat(refreshed.path("refreshToken").asText()).isNotEqualTo(oldRefreshToken); + callProtectedApi(refreshed.path("accessToken").asText()).andExpect(status().isOk()); + } + + @Test + @DisplayName("갱신해도 권한은 그대로 유지된다") + void keepsAuthorityAcrossRefresh() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + JsonNode refreshed = dataOf(refresh(refreshToken).andExpect(status().isOk()).andReturn()); + + assertThat(validTokenizer.getAuthorityFromAccessToken( + refreshed.path("accessToken").asText().replace("Bearer ", ""))) + .as("갱신 한 번으로 권한이 올라가면 안 된다") + .isEqualTo(Authority.ROLE_GUEST); + } + + /** + * 1002 가 401 이어야 스토어 4.0.0 이 토큰을 지우고 로그인 화면으로 간다. + * 그 빌드는 400·403 일 때만 errorCode 를 보고, 그 밖에는 401 여부로만 판정한다(#265). + */ + @Test + @DisplayName("회전된 옛 리프레시 토큰을 다시 쓰면 401 + 1002 로 거절한다") + void rejectsRotatedRefreshToken() throws Exception { + String oldRefreshToken = signUpGuest().path("refreshToken").asText(); + refresh(oldRefreshToken).andExpect(status().isOk()); + + refresh(oldRefreshToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("서명은 유효하지만 저장된 적 없는 리프레시 토큰은 401 + 1002 로 거절한다") + void rejectsUnknownRefreshToken() throws Exception { + String neverStored = validTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + refresh(neverStored) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 401 + 1006 으로 거절한다") + void rejectsExpiredRefreshToken() throws Exception { + String expired = expiredTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + refresh(expired) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_EXPIRED.getErrorCode())); + } + + @ParameterizedTest(name = "형식이 깨진 리프레시 토큰 [{0}] 은 400 + 1001 로 거절한다") + @ValueSource(strings = {"not-a-jwt", "a.b.c", ""}) + void rejectsMalformedRefreshToken(String refreshToken) throws Exception { + refresh(refreshToken) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_REFRESH_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("refreshToken 을 빠뜨린 요청도 500 이 아니라 400 + 1001 로 거절한다") + void rejectsMissingRefreshToken() throws Exception { + refresh(null) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_REFRESH_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("갱신은 인증 없이도 호출할 수 있다") + void doesNotRequireAccessToken() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + refresh(refreshToken).andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + private ResultActions logout(String accessToken, String refreshToken) throws Exception { + return mockMvc.perform(post("/api/auth/logout") + .header("Authorization", accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequestDto(accessToken, refreshToken)))); + } + + @Test + @DisplayName("로그아웃하면 세션이 지워지고 액세스 토큰은 블랙리스트로 막힌다") + void invalidatesSessionAndAccessToken() throws Exception { + JsonNode tokens = signUpGuest(); + String accessToken = tokens.path("accessToken").asText(); + String refreshToken = tokens.path("refreshToken").asText(); + + logout(accessToken, refreshToken).andExpect(status().isOk()); + + assertThat(refreshTokenRepository.findByRefreshToken(refreshToken)).isEmpty(); + callProtectedApi(accessToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("로그아웃한 리프레시 토큰으로는 갱신할 수 없다") + void cannotRefreshAfterLogout() throws Exception { + JsonNode tokens = signUpGuest(); + logout(tokens.path("accessToken").asText(), tokens.path("refreshToken").asText()) + .andExpect(status().isOk()); + + refresh(tokens.path("refreshToken").asText()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("리프레시 토큰 없이 로그아웃해도 액세스 토큰은 막힌다") + void worksWithoutRefreshToken() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + mockMvc.perform(post("/api/auth/logout").header("Authorization", accessToken)) + .andExpect(status().isOk()); + + callProtectedApi(accessToken).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("남의 리프레시 토큰을 넣어 로그아웃해도 그 사람의 세션은 살아 있다") + void cannotKillOtherUsersSession() throws Exception { + JsonNode victim = signUpMember("google-sub-victim", "GOOGLE"); + JsonNode attacker = signUpMember("google-sub-attacker", "GOOGLE"); + + logout(attacker.path("accessToken").asText(), victim.path("refreshToken").asText()) + .andExpect(status().isOk()); + + assertThat(refreshTokenRepository.findByRefreshToken(victim.path("refreshToken").asText())) + .as("남의 세션을 지울 수 있으면 토큰 문자열만으로 강제 로그아웃이 가능해진다") + .isPresent(); + refresh(victim.path("refreshToken").asText()).andExpect(status().isOk()); + } + + @Test + @DisplayName("만료된 액세스 토큰으로 로그아웃하면 401 + 1005 로 막고 세션은 남긴다") + void expiredAccessTokenLogoutIsRejected() throws Exception { + JsonNode tokens = signUpGuest(); + String expiredAccessToken = expiredTokenizer.createAccessToken( + "1", Map.of("authority", Authority.ROLE_GUEST)); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", expiredAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TokenRequestDto(expiredAccessToken, tokens.path("refreshToken").asText())))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode())); + + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("인증이 안 됐으므로 컨트롤러까지 가지 못해 세션은 그대로 남는다") + .isPresent(); + } + + @Test + @DisplayName("인증 없이 로그아웃하면 401 + 1007 로 막는다") + void anonymousLogoutIsRejected() throws Exception { + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("쓰레기 토큰으로 로그아웃하면 401 + 1009 로 막고 세션은 남긴다") + void garbageAccessTokenLogoutIsRejected() throws Exception { + JsonNode tokens = signUpGuest(); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer not-a-real-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TokenRequestDto(null, tokens.path("refreshToken").asText())))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("막지 못하면 토큰 없이 남의 세션을 지울 수 있게 된다") + .isPresent(); + } + + @Test + @DisplayName("Bearer 형식이 아닌 헤더로 로그아웃하면 401 + 1007 로 막는다") + void nonBearerHeaderLogoutIsRejected() throws Exception { + mockMvc.perform(post("/api/auth/logout").header("Authorization", "garbage")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("정상 토큰이면 200 과 함께 세션을 실제로 지운다") + void validTokenLogoutClearsSession() throws Exception { + JsonNode tokens = signUpGuest(); + String accessToken = tokens.path("accessToken").asText(); + String refreshToken = tokens.path("refreshToken").asText(); + + logout(accessToken, refreshToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("로그아웃 되었습니다.")); + + assertThat(refreshTokenRepository.findByRefreshToken(refreshToken)).isEmpty(); + } + } + + @Nested + @DisplayName("권한 경계") + class AuthorityBoundary { + + @Test + @DisplayName("게스트 토큰도 사용자 API 는 쓸 수 있다") + void guestCanUseUserApi() throws Exception { + callProtectedApi(signUpGuest().path("accessToken").asText()).andExpect(status().isOk()); + } + + @Test + @DisplayName("멤버 토큰으로 관리자 경로에 접근하면 403 + 1008 로 막는다") + void memberCannotAccessAdminPath() throws Exception { + String memberToken = signUpMember("google-sub-member", "GOOGLE").path("accessToken").asText(); + + mockMvc.perform(get("/admin/main").header("Authorization", memberToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_DENIED.getErrorCode())); + } + + @Test + @DisplayName("인증 없이 관리자 경로에 접근하면 401 + 1007 로 막는다") + void anonymousCannotAccessAdminPath() throws Exception { + mockMvc.perform(get("/admin/main")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java deleted file mode 100644 index c8d9b6b7..00000000 --- a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.aisip.OnO.backend.auth.controller; - -import com.aisip.OnO.backend.auth.dto.TokenRequestDto; -import com.aisip.OnO.backend.auth.dto.TokenResponseDto; -import com.aisip.OnO.backend.auth.service.UserAuthService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -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.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@ExtendWith(SpringExtension.class) -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -class AuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private UserAuthService userAuthService; - - @Autowired - private ObjectMapper objectMapper; - - @Test - @DisplayName("게스트 유저 회원가입") - void signUpGuest() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("mockAccessToken", "mockRefreshToken"); - given(userAuthService.signUpGuestUser()).willReturn(mockTokenResponse); - - // When & Then - mockMvc.perform(post("/api/auth/signup/guest") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("mockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - } - - @Test - @DisplayName("멤버 유저 회원가입") - void signUpMember() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("mockAccessToken", "mockRefreshToken"); - given(userAuthService.signUpMemberUser(any())).willReturn(mockTokenResponse); - - // When & Then - mockMvc.perform(post("/api/auth/signup/member") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new com.aisip.OnO.backend.user.dto.UserRegisterDto( - "test@example.com", "testUser", "testIdentifier", "MEMBER", null - )))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("mockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - } - - @Test - @DisplayName("토큰 갱신") - void refreshToken() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("newMockAccessToken", "mockRefreshToken"); - given(userAuthService.refreshAccessToken(any())).willReturn(mockTokenResponse); - - TokenRequestDto requestDto = new TokenRequestDto("mockAccessToken", "mockRefreshToken"); - - // When & Then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(requestDto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("newMockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - - // Verify userAuthService 호출 검증 - Mockito.verify(userAuthService, Mockito.times(1)).refreshAccessToken(any()); - } -} diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java new file mode 100644 index 00000000..7ff85f29 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java @@ -0,0 +1,113 @@ +package com.aisip.OnO.backend.auth.controller; + +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 탈퇴한 계정에게 이미 나간 액세스 토큰을 더 이상 받아주지 않는지 확인한다. + * + *

액세스 토큰은 서명과 만료만으로 통과하므로, 탈퇴해도 만료 전(최대 30분)까지는 살아 있다. + * 사용자 존재를 확인하는 경로는 404 로 떨어지지만, 확인하지 않는 {@code POST /api/fcm/token} 은 + * 200 으로 성공해 탈퇴 때 지운 {@code fcm_token} 행이 되살아났다. 그러면 그 기기를 이어 쓰는 사람에게 + * 탈퇴한 계정 앞으로 가는 알림이 뜬다. (#300, #271 이 막으려던 상황과 같은 결과) + * + *

여기서는 실제 {@code Authorization} 헤더로 요청을 보내 필터를 그대로 태운다. + * 인증 컨텍스트를 직접 세우는 테스트는 이 경로를 지나지 않아 회귀를 잡지 못한다. + */ +@DisplayName("탈퇴한 계정의 액세스 토큰") +class DeletedUserAccessTokenApiTest extends IntegrationTestSupport { + + private static final String FCM_TOKEN_API = "/api/fcm/token"; + + private JsonNode dataOf(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); + } + + /** 게스트로 가입하고 "Bearer " 를 포함한 액세스 토큰을 돌려준다. */ + private String signUpGuestAndGetAccessToken() throws Exception { + MvcResult result = mockMvc.perform(post("/api/auth/signup/guest")) + .andExpect(status().isOk()) + .andReturn(); + return dataOf(result).path("accessToken").asText(); + } + + private ResultActions registerFcmToken(String authorizationHeader, String fcmToken) throws Exception { + return mockMvc.perform(post(FCM_TOKEN_API) + .header("Authorization", authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDto(fcmToken)))); + } + + private void withdraw(String authorizationHeader) throws Exception { + mockMvc.perform(delete("/api/users").header("Authorization", authorizationHeader)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("탈퇴 후 남은 토큰으로 FCM 토큰을 등록하면 401 + 1009 로 거절한다") + void rejectsFcmTokenRegistrationAfterWithdrawal() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + withdraw(accessToken); + + registerFcmToken(accessToken, "fcm-token-after-withdrawal") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + + verify(fcmService, never()).registerToken(any(), any()); + } + + @Test + @DisplayName("탈퇴 후 남은 토큰은 사용자 조회도 401 로 막는다") + void rejectsUserLookupAfterWithdrawal() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + withdraw(accessToken); + + mockMvc.perform(get("/api/users").header("Authorization", accessToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("탈퇴하지 않은 사용자는 FCM 토큰 등록이 그대로 된다") + void allowsFcmTokenRegistrationForLivingUser() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + + registerFcmToken(accessToken, "fcm-token-living-user") + .andExpect(status().isOk()); + + verify(fcmService).registerToken(eq(new FcmTokenRequestDto("fcm-token-living-user")), any()); + } + + @Test + @DisplayName("남이 탈퇴해도 내 토큰은 영향을 받지 않는다") + void withdrawalOfAnotherUserDoesNotAffectMe() throws Exception { + String myAccessToken = signUpGuestAndGetAccessToken(); + String otherAccessToken = signUpGuestAndGetAccessToken(); + + withdraw(otherAccessToken); + + registerFcmToken(myAccessToken, "fcm-token-of-mine") + .andExpect(status().isOk()); + mockMvc.perform(get("/api/users").header("Authorization", myAccessToken)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java index bbe619e0..cc56d60d 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java @@ -3,27 +3,46 @@ import com.aisip.OnO.backend.auth.dto.TokenResponseDto; import com.aisip.OnO.backend.auth.entity.Authority; import com.aisip.OnO.backend.auth.entity.RefreshToken; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.util.redis.RedisTokenService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; 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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.util.Map; import java.util.Optional; 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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +/** + * 토큰 발급 → 회전 → 폐기의 단위 계약. + * + *

refresh 는 세션 하나(제출된 토큰이 가리키는 row)만 회전시켜야 한다. + * userId 로 찾아 덮어쓰면 여러 기기 로그인 중 한 기기가 갱신할 때 + * 나머지 기기가 전부 1002 로 튕긴다. + */ @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("JwtTokenService") class JwtTokenServiceTest { private static final String ACCESS_SECRET = @@ -31,6 +50,8 @@ class JwtTokenServiceTest { private static final String REFRESH_SECRET = "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + private static final Long USER_ID = 1L; + @Mock private RedisTokenService redisTokenService; @@ -38,66 +59,269 @@ class JwtTokenServiceTest { private RefreshTokenRepository refreshTokenRepository; private JwtTokenizer jwtTokenizer; + private JwtTokenizer expiredTokenizer; private JwtTokenService jwtTokenService; @BeforeEach void setUp() { - jwtTokenizer = new JwtTokenizer( - 1_800_000L, - 60_480_000_000L, - ACCESS_SECRET, - REFRESH_SECRET - ); + jwtTokenizer = new JwtTokenizer(1_800_000L, 604_800_000L, ACCESS_SECRET, REFRESH_SECRET); + expiredTokenizer = new JwtTokenizer(-60_000L, -60_000L, ACCESS_SECRET, REFRESH_SECRET); jwtTokenService = new JwtTokenService(jwtTokenizer, redisTokenService, refreshTokenRepository); + + given(refreshTokenRepository.save(any(RefreshToken.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + } + + private String refreshTokenOf(Authority authority) { + return jwtTokenizer.createRefreshToken(String.valueOf(USER_ID), Map.of("authority", authority)); } - @Test - @DisplayName("로그인 시 기존 세션을 덮어쓰지 않고 새 refresh token row를 저장한다") - void generateTokensCreatesNewRefreshTokenSession() { - // given - Long userId = 1L; - Authority authority = Authority.ROLE_MEMBER; - given(refreshTokenRepository.save(any(RefreshToken.class))).willAnswer(invocation -> invocation.getArgument(0)); - - // when - TokenResponseDto response = jwtTokenService.generateTokens(userId, authority); - - // then - assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); - - ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); - verify(refreshTokenRepository).save(tokenCaptor.capture()); - verify(refreshTokenRepository, never()).findByUserId(userId); - verify(redisTokenService, never()).saveRefreshToken(eq(userId), eq(response.getRefreshToken()), any(Long.class)); - - RefreshToken savedRefreshToken = tokenCaptor.getValue(); - assertThat(savedRefreshToken.getUserId()).isEqualTo(userId); - assertThat(savedRefreshToken.getAuthority()).isEqualTo(authority); - assertThat(savedRefreshToken.getRefreshToken()).isEqualTo(response.getRefreshToken()); + @Nested + @DisplayName("토큰 발급") + class GenerateTokens { + + @Test + @DisplayName("액세스·리프레시 토큰을 함께 발급하고 리프레시 토큰 row 를 새로 저장한다") + void issuesBothTokensAndPersistsSession() { + TokenResponseDto response = jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenRepository).save(captor.capture()); + + assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); + assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID); + assertThat(captor.getValue().getAuthority()).isEqualTo(Authority.ROLE_MEMBER); + assertThat(captor.getValue().getRefreshToken()) + .as("응답으로 준 토큰과 저장한 토큰이 다르면 갱신 시 1002 가 난다") + .isEqualTo(response.getRefreshToken()); + } + + @Test + @DisplayName("기존 세션을 userId 로 찾아 덮어쓰지 않는다") + void doesNotOverwriteExistingSessionByUserId() { + jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + verify(refreshTokenRepository, never()).findByUserId(anyLong()); + verify(refreshTokenRepository, never()).deleteByUserId(anyLong()); + } + + @Test + @DisplayName("발급한 토큰의 권한 클레임은 요청한 권한과 같다") + void embedsRequestedAuthority() { + TokenResponseDto response = jwtTokenService.generateTokens(USER_ID, Authority.ROLE_GUEST); + + assertThat(jwtTokenizer.getAuthorityFromRefreshToken(response.getRefreshToken())) + .isEqualTo(Authority.ROLE_GUEST); + } + + @Test + @DisplayName("발급 단계에서는 Redis 를 쓰지 않는다") + void doesNotTouchRedis() { + jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + verify(redisTokenService, never()).saveRefreshToken(anyLong(), anyString(), anyLong()); + } } - @Test - @DisplayName("refresh API 호출 시 제출된 refresh token 세션만 회전한다") - void refreshAccessTokenRotatesCurrentRefreshTokenSession() { - // given - Long userId = 1L; - Authority authority = Authority.ROLE_MEMBER; - String oldRefreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); - RefreshToken existingRefreshToken = RefreshToken.from(userId, authority, oldRefreshToken); - - given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(existingRefreshToken)); - given(refreshTokenRepository.save(any(RefreshToken.class))).willAnswer(invocation -> invocation.getArgument(0)); - - // when - TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); - - // then - assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); - assertThat(response.getRefreshToken()).isNotEqualTo(oldRefreshToken); - assertThat(existingRefreshToken.getRefreshToken()).isEqualTo(response.getRefreshToken()); - - verify(refreshTokenRepository).save(existingRefreshToken); - verify(refreshTokenRepository, never()).findByUserId(userId); - verify(redisTokenService, never()).saveRefreshToken(eq(userId), eq(response.getRefreshToken()), any(Long.class)); + @Nested + @DisplayName("토큰 갱신") + class RefreshAccessToken { + + @Test + @DisplayName("제출한 리프레시 토큰이 가리키는 세션만 회전시킨다") + void rotatesOnlySubmittedSession() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, oldRefreshToken); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(session)); + + TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); + + assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); + assertThat(response.getRefreshToken()).isNotEqualTo(oldRefreshToken); + assertThat(session.getRefreshToken()) + .as("DB row 도 새 토큰으로 갱신돼야 다음 갱신이 통한다") + .isEqualTo(response.getRefreshToken()); + verify(refreshTokenRepository).save(session); + verify(refreshTokenRepository, never()).findByUserId(anyLong()); + } + + @Test + @DisplayName("갱신해도 권한은 그대로 유지된다") + void keepsAuthority() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_GUEST); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)) + .willReturn(Optional.of(RefreshToken.from(USER_ID, Authority.ROLE_GUEST, oldRefreshToken))); + + TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); + + assertThat(jwtTokenizer.getAuthorityFromAccessToken( + response.getAccessToken().replace(JwtTokenizer.BEARER_PREFIX, ""))) + .as("게스트가 갱신 한 번으로 멤버 권한을 얻으면 안 된다") + .isEqualTo(Authority.ROLE_GUEST); + } + + @Test + @DisplayName("DB 에 없는 리프레시 토큰이면 1002 로 거절한다") + void rejectsUnknownRefreshTokenWithNotFound() { + String unknown = refreshTokenOf(Authority.ROLE_MEMBER); + given(refreshTokenRepository.findByRefreshToken(unknown)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(unknown)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .as("프로덕션 1002 응답이 나오는 지점") + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("이미 회전시킨 옛 리프레시 토큰을 재사용하면 1002 로 거절한다") + void rejectsReusedRefreshToken() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, oldRefreshToken); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(session)); + + jwtTokenService.refreshAccessToken(oldRefreshToken); + + // 회전 후에는 옛 토큰으로 더 이상 세션을 찾을 수 없다. + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(oldRefreshToken)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 DB 조회 전에 1006 으로 거절한다") + void rejectsExpiredRefreshTokenBeforeLookup() { + String expired = expiredTokenizer.createRefreshToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(expired)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_EXPIRED); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + } + + @Test + @DisplayName("형식이 깨진 리프레시 토큰은 1001 로 거절한다") + void rejectsMalformedRefreshToken() { + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken("not-a-jwt")) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + } + + @Test + @DisplayName("refreshToken 이 null 이면 500 이 아니라 1001 로 거절한다") + void rejectsNullRefreshToken() { + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(null)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + @Test + @DisplayName("제출한 리프레시 토큰 세션을 지우고 액세스 토큰을 남은 시간만큼 블랙리스트에 넣는다") + void deletesSessionAndBlacklistsAccessToken() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + String refreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, refreshToken); + given(refreshTokenRepository.findByRefreshToken(refreshToken)).willReturn(Optional.of(session)); + + jwtTokenService.logout(accessToken, USER_ID, refreshToken); + + verify(refreshTokenRepository).delete(session); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + verify(redisTokenService).addToBlacklist( + eq(accessToken.replace(JwtTokenizer.BEARER_PREFIX, "")), ttlCaptor.capture()); + assertThat(ttlCaptor.getValue()) + .as("남은 만료 시간만큼만 블랙리스트에 두어야 Redis 가 새지 않는다") + .isBetween(1L, 1_800L); + } + + @Test + @DisplayName("Bearer 프리픽스 없이 액세스 토큰만 보내도 블랙리스트에 넣는다") + void acceptsRawAccessToken() { + String rawAccessToken = jwtTokenizer + .createAccessToken(String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)) + .replace(JwtTokenizer.BEARER_PREFIX, ""); + + jwtTokenService.logout(rawAccessToken, USER_ID, null); + + verify(redisTokenService).addToBlacklist(eq(rawAccessToken), anyLong()); + } + + @Test + @DisplayName("리프레시 토큰을 함께 보내지 않으면 세션 삭제를 시도하지 않는다") + void skipsSessionDeletionWithoutRefreshToken() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + jwtTokenService.logout(accessToken, USER_ID, " "); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + verify(refreshTokenRepository, never()).delete(any(RefreshToken.class)); + } + + @Test + @DisplayName("다른 사용자의 리프레시 토큰을 넘기면 그 세션을 지우지 않는다") + void doesNotDeleteOtherUsersSession() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + String othersRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + given(refreshTokenRepository.findByRefreshToken(othersRefreshToken)) + .willReturn(Optional.of(RefreshToken.from(999L, Authority.ROLE_MEMBER, othersRefreshToken))); + + jwtTokenService.logout(accessToken, USER_ID, othersRefreshToken); + + verify(refreshTokenRepository, never()).delete(any(RefreshToken.class)); + } + + @Test + @DisplayName("이미 만료된 액세스 토큰은 블랙리스트에 넣지 않는다") + void doesNotBlacklistExpiredAccessToken() { + String expiredAccessToken = expiredTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatCode(() -> jwtTokenService.logout(expiredAccessToken, USER_ID, null)) + .as("만료 토큰으로 로그아웃해도 500 이 나면 안 된다") + .doesNotThrowAnyException(); + + verify(redisTokenService, never()).addToBlacklist(anyString(), anyLong()); + } + + @Test + @DisplayName("형식이 깨진 액세스 토큰으로 로그아웃해도 예외 없이 끝난다") + void toleratesMalformedAccessToken() { + assertThatCode(() -> jwtTokenService.logout("Bearer not-a-jwt", USER_ID, null)) + .doesNotThrowAnyException(); + + verify(redisTokenService, never()).addToBlacklist(anyString(), anyLong()); + } + + @Test + @DisplayName("Redis 가 죽어 블랙리스트 등록이 실패해도 로그아웃은 성공 처리한다") + void survivesRedisFailure() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + willThrow(new IllegalStateException("redis down")) + .given(redisTokenService).addToBlacklist(anyString(), anyLong()); + + assertThatCode(() -> jwtTokenService.logout(accessToken, USER_ID, null)) + .as("로그아웃이 실패하면 사용자는 앱에서 빠져나가지 못한다") + .doesNotThrowAnyException(); + } } } diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java new file mode 100644 index 00000000..b6aab271 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java @@ -0,0 +1,362 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * JWT 발급·검증의 단위 계약을 고정한다. + * + *

여기서 정하는 예외 타입이 그대로 {@code JwtTokenFilter} 의 분기 조건이 되고, + * 필터가 고른 {@link AuthErrorCase} 가 프론트의 토큰 갱신 트리거가 된다. + * 즉 이 파일의 assertion 은 프론트 동작과 직결된 계약이다. + */ +@DisplayName("JwtTokenizer") +class JwtTokenizerTest { + + private static final String ACCESS_SECRET = + "dGVzdC1hY2Nlc3MtdG9rZW4tc2VjcmV0LXRlc3QtYWNjZXNzLXRva2VuLXNlY3JldC0zMmJ5dGVz"; + private static final String REFRESH_SECRET = + "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + private static final String OTHER_SECRET = + "b3RoZXItc2VjcmV0LWtleS1vdGhlci1zZWNyZXQta2V5LW90aGVyLXNlY3JldC0zMmJ5dGVz"; + + private static final long ACCESS_TOKEN_EXPIRATION_MILLIS = 1_800_000L; + private static final long REFRESH_TOKEN_EXPIRATION_MILLIS = 604_800_000L; + + private final JwtTokenizer tokenizer = newTokenizer(ACCESS_TOKEN_EXPIRATION_MILLIS, REFRESH_TOKEN_EXPIRATION_MILLIS); + + /** 이미 만료된 토큰만 발급하는 토크나이저. 시계를 조작하지 않고 만료 상황을 재현한다. */ + private final JwtTokenizer expiredTokenizer = newTokenizer(-60_000L, -60_000L); + + private static JwtTokenizer newTokenizer(long accessExpiration, long refreshExpiration) { + return new JwtTokenizer(accessExpiration, refreshExpiration, ACCESS_SECRET, REFRESH_SECRET); + } + + private String accessTokenOf(long userId, Authority authority) { + return stripBearer(tokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority))); + } + + private String refreshTokenOf(long userId, Authority authority) { + return tokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); + } + + private static String stripBearer(String token) { + return token.replace(JwtTokenizer.BEARER_PREFIX, ""); + } + + @Nested + @DisplayName("액세스 토큰 발급") + class CreateAccessToken { + + @Test + @DisplayName("Authorization 헤더에 그대로 넣을 수 있도록 Bearer 프리픽스를 붙여 발급한다") + void prefixesBearer() { + String token = tokenizer.createAccessToken("42", Map.of("authority", Authority.ROLE_MEMBER)); + + assertThat(token) + .as("프론트는 발급값을 그대로 Authorization 헤더에 넣는다") + .startsWith("Bearer "); + } + + @Test + @DisplayName("subject 와 authority 클레임이 그대로 왕복한다") + void carriesSubjectAndAuthority() { + String token = accessTokenOf(42L, Authority.ROLE_GUEST); + + assertThat(tokenizer.getUserIdFromAccessToken(token)).isEqualTo(42L); + assertThat(tokenizer.getAuthorityFromAccessToken(token)).isEqualTo(Authority.ROLE_GUEST); + } + + @ParameterizedTest(name = "{0} 권한도 손실 없이 왕복한다") + @EnumSource(Authority.class) + void carriesEveryAuthority(Authority authority) { + String token = accessTokenOf(7L, authority); + + assertThat(tokenizer.getAuthorityFromAccessToken(token)).isEqualTo(authority); + } + + @Test + @DisplayName("만료 시각은 설정한 accessToken 만료 시간과 같다") + void expiresAfterConfiguredDuration() { + String token = accessTokenOf(1L, Authority.ROLE_MEMBER); + + assertThat(tokenizer.getRemainingExpirationTime(token)) + .as("설정값 %d ms 안쪽이어야 한다", ACCESS_TOKEN_EXPIRATION_MILLIS) + .isBetween(ACCESS_TOKEN_EXPIRATION_MILLIS / 1000 - 5, ACCESS_TOKEN_EXPIRATION_MILLIS / 1000); + assertThat(tokenizer.getAccessTokenExpirationSeconds()).isEqualTo(1_800L); + } + } + + @Nested + @DisplayName("리프레시 토큰 발급") + class CreateRefreshToken { + + @Test + @DisplayName("리프레시 토큰에는 Bearer 프리픽스를 붙이지 않는다") + void hasNoBearerPrefix() { + assertThat(refreshTokenOf(42L, Authority.ROLE_MEMBER)) + .as("DB 에 저장되고 body 로 오가는 값이라 프리픽스가 붙으면 조회가 어긋난다") + .doesNotStartWith("Bearer "); + } + + @Test + @DisplayName("같은 사용자가 연속 발급해도 jti 덕분에 매번 다른 토큰이 나온다") + void issuesDistinctTokensForSameUser() { + String first = refreshTokenOf(42L, Authority.ROLE_MEMBER); + String second = refreshTokenOf(42L, Authority.ROLE_MEMBER); + + assertThat(second) + .as("같은 값이면 회전(rotation)해도 이전 세션과 구분되지 않는다") + .isNotEqualTo(first); + } + + @Test + @DisplayName("리프레시 토큰 문자열이 refresh_token 컬럼(varchar 255)에 들어간다") + void fitsInRefreshTokenColumn() { + int longest = IntStream.range(0, 50) + .map(i -> refreshTokenOf(Long.MAX_VALUE, Authority.ROLE_MEMBER).length()) + .max() + .orElseThrow(); + + System.out.println("발급된 refresh token 최대 길이 = " + longest); + assertThat(longest) + .as("길이가 255 를 넘으면 저장 시 잘리거나 실패해 이후 조회가 1002 로 떨어진다") + .isLessThan(255); + } + + @Test + @DisplayName("만료 시각은 설정한 refreshToken 만료 시간과 같다") + void expiresAfterConfiguredDuration() { + String token = refreshTokenOf(1L, Authority.ROLE_MEMBER); + + assertThat(tokenizer.getRemainingRefreshExpirationTime(token)) + .isBetween(REFRESH_TOKEN_EXPIRATION_MILLIS / 1000 - 5, REFRESH_TOKEN_EXPIRATION_MILLIS / 1000); + assertThat(tokenizer.getRefreshTokenExpirationSeconds()).isEqualTo(604_800L); + } + } + + @Nested + @DisplayName("액세스 토큰 검증") + class ValidateAccessToken { + + @Test + @DisplayName("정상 토큰은 그대로 통과한다") + void acceptsValidToken() { + assertThatCode(() -> tokenizer.validateAccessToken(accessTokenOf(1L, Authority.ROLE_MEMBER))) + .doesNotThrowAnyException(); + } + + /** + * 만료만은 ApplicationException 으로 감싸지 않고 ExpiredJwtException 그대로 던진다. + * JwtTokenFilter 가 ExpiredJwtException 을 잡아 ACCESS_TOKEN_EXPIRED(1005) 로 응답하고, + * 프론트는 1005 를 보고 토큰 갱신을 시도하기 때문이다. + */ + @Test + @DisplayName("만료된 토큰은 ACCESS_TOKEN_EXPIRED 로 구분되어 올라간다") + void distinguishesExpiredToken() { + String expired = stripBearer( + expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER))); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(expired)) + .as("만료가 다른 실패와 섞이면 프론트가 갱신 기회를 잃는다") + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED); + } + + @Test + @DisplayName("다른 키로 서명한 위조 토큰은 유효하지 않은 액세스 토큰으로 거절한다") + void rejectsForgedSignature() { + String forged = Jwts.builder() + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(forged)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + } + + @Test + @DisplayName("페이로드를 갈아끼운 토큰은 서명 불일치로 거절한다") + void rejectsTamperedPayload() { + String token = accessTokenOf(1L, Authority.ROLE_MEMBER); + String[] parts = token.split("\\."); + String tamperedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString( + new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8) + .replace("\"sub\":\"1\"", "\"sub\":\"999\"") + .getBytes(StandardCharsets.UTF_8)); + String tampered = parts[0] + "." + tamperedPayload + "." + parts[2]; + + assertThatThrownBy(() -> tokenizer.validateAccessToken(tampered)) + .as("남의 userId 로 갈아끼운 토큰이 통과하면 전 사용자 데이터가 열린다") + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("서명 없는 alg=none 토큰은 거절한다") + void rejectsUnsignedToken() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"sub\":\"1\",\"authority\":\"ROLE_ADMIN\"}".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(header + "." + payload + ".")) + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("리프레시 토큰을 액세스 토큰 자리에 넣으면 거절한다") + void rejectsRefreshTokenAsAccessToken() { + assertThatThrownBy(() -> tokenizer.validateAccessToken(refreshTokenOf(1L, Authority.ROLE_MEMBER))) + .as("서명 키가 분리돼 있어야 리프레시 토큰만으로 API 를 호출할 수 없다") + .isInstanceOf(ApplicationException.class); + } + + @ParameterizedTest(name = "형식이 깨진 토큰 [{0}] 은 거절한다") + @ValueSource(strings = {"", " ", "not-a-jwt", "a.b", "a.b.c.d", "Bearer eyJhbGciOiJIUzI1NiJ9"}) + void rejectsMalformedToken(String malformed) { + assertThatThrownBy(() -> tokenizer.validateAccessToken(malformed)) + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("null 토큰도 예외 계약을 지킨다") + void rejectsNullToken() { + assertThatThrownBy(() -> tokenizer.validateAccessToken(null)) + .isInstanceOf(ApplicationException.class); + } + } + + @Nested + @DisplayName("리프레시 토큰 검증") + class ValidateRefreshToken { + + @Test + @DisplayName("정상 토큰은 그대로 통과한다") + void acceptsValidToken() { + assertThatCode(() -> tokenizer.validateRefreshToken(refreshTokenOf(1L, Authority.ROLE_MEMBER))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 1006 으로 거절한다") + void rejectsExpiredWithRefreshTokenExpired() { + String expired = expiredTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatThrownBy(() -> tokenizer.validateRefreshToken(expired)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .as("프론트는 1006 을 재로그인 신호로 쓴다") + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_EXPIRED); + } + + @Test + @DisplayName("위조 서명 리프레시 토큰은 1001 로 거절한다") + void rejectsForgedWithInvalidRefreshToken() { + String forged = Jwts.builder() + .setSubject("1") + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.validateRefreshToken(forged)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @Test + @DisplayName("액세스 토큰을 리프레시 토큰 자리에 넣으면 1001 로 거절한다") + void rejectsAccessTokenAsRefreshToken() { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(accessTokenOf(1L, Authority.ROLE_MEMBER))) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @ParameterizedTest(name = "형식이 깨진 리프레시 토큰 [{0}] 은 1001 로 거절한다") + @ValueSource(strings = {"", " ", "not-a-jwt", "a.b.c"}) + void rejectsMalformedToken(String malformed) { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(malformed)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @Test + @DisplayName("null 리프레시 토큰도 1001 로 거절한다") + void rejectsNullToken() { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(null)) + .as("본문에 refreshToken 을 빠뜨린 요청이 500 이 되면 안 된다") + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + } + + @Nested + @DisplayName("클레임 추출") + class ExtractClaims { + + @Test + @DisplayName("리프레시 토큰에서 userId 와 권한을 읽는다") + void readsUserIdAndAuthorityFromRefreshToken() { + String token = refreshTokenOf(123L, Authority.ROLE_ADMIN); + + assertThat(tokenizer.getUserIdFromRefreshToken(token)).isEqualTo(123L); + assertThat(tokenizer.getAuthorityFromRefreshToken(token)).isEqualTo(Authority.ROLE_ADMIN); + } + + @Test + @DisplayName("만료된 토큰에서 클레임을 읽으면 ExpiredJwtException 이 난다") + void failsToReadClaimsFromExpiredToken() { + String expired = stripBearer( + expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER))); + + assertThatThrownBy(() -> tokenizer.getRemainingExpirationTime(expired)) + .as("로그아웃 시 만료 토큰을 블랙리스트에 넣지 않도록 하는 근거가 되는 동작이다") + .isInstanceOf(ExpiredJwtException.class); + } + + @Test + @DisplayName("권한 클레임이 없는 토큰은 권한 조회에서 실패한다") + void failsWhenAuthorityClaimMissing() { + String noAuthority = Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(REFRESH_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.getAuthorityFromRefreshToken(noAuthority)) + .isInstanceOf(Exception.class); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java new file mode 100644 index 00000000..753257a0 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java @@ -0,0 +1,85 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 만료된 액세스 토큰이 어떤 ErrorCase 로 나가는지 고정한다. + * + * 프론트(HttpService.dart)는 errorCode 1005 일 때만 토큰 갱신을 재시도하고, + * 1000~1999 의 다른 코드는 인증 실패로 보고 강제 로그아웃시킨다. + * 그래서 "만료 → 1005" 계약이 깨지면 리프레시 토큰이 멀쩡한 사용자가 로그아웃된다. + */ +class JwtTokenizerValidationTest { + + private static final String ACCESS_SECRET = + "dGVzdC1hY2Nlc3MtdG9rZW4tc2VjcmV0LXRlc3QtYWNjZXNzLXRva2VuLXNlY3JldC0zMmJ5dGVz"; + private static final String REFRESH_SECRET = + "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + + private JwtTokenizer expiringTokenizer; + private JwtTokenizer validTokenizer; + + @BeforeEach + void setUp() { + // 액세스 토큰 유효기간을 음수로 줘서 발급 즉시 만료된 토큰을 만든다 + expiringTokenizer = new JwtTokenizer(-1_000L, 60_480_000_000L, ACCESS_SECRET, REFRESH_SECRET); + validTokenizer = new JwtTokenizer(1_800_000L, 60_480_000_000L, ACCESS_SECRET, REFRESH_SECRET); + } + + private String issueAccessToken(JwtTokenizer tokenizer) { + return tokenizer + .createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER.name())) + .substring(JwtTokenizer.BEARER_PREFIX.length()) + .trim(); + } + + @Test + @DisplayName("만료된 액세스 토큰은 ACCESS_TOKEN_EXPIRED(1005) 로 판정된다") + void expiredAccessTokenMapsTo1005() { + String expired = issueAccessToken(expiringTokenizer); + + assertThatThrownBy(() -> validTokenizer.validateAccessToken(expired)) + .isInstanceOf(ApplicationException.class) + .satisfies(e -> assertThat(((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED)); + + assertThat(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode()).isEqualTo(1005); + } + + @Test + @DisplayName("서명이 틀린 액세스 토큰은 만료가 아니라 INVALID_ACCESS_TOKEN(1009) 로 판정된다") + void tamperedAccessTokenMapsTo1009() { + String token = issueAccessToken(validTokenizer); + // 서명부 한 글자를 바꿔 검증에 실패하게 만든다. + // 마지막 글자는 32바이트를 base64url 43글자로 담고 남은 비트라 바꿔도 같은 바이트로 디코딩될 수 있다. + // (HS256 서명이 'A' 로 끝나면 'B' 로 바꿔도 서명이 그대로여서 검증을 통과해 버렸다) + // 첫 글자는 6비트가 모두 쓰이므로 다른 글자로 바꾸면 서명이 반드시 달라진다. + int signatureStart = token.lastIndexOf('.') + 1; + String tampered = token.substring(0, signatureStart) + + (token.charAt(signatureStart) == 'A' ? 'B' : 'A') + + token.substring(signatureStart + 1); + + assertThatThrownBy(() -> validTokenizer.validateAccessToken(tampered)) + .isInstanceOf(ApplicationException.class) + .satisfies(e -> assertThat(((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN)); + } + + @Test + @DisplayName("정상 액세스 토큰은 예외 없이 통과한다") + void validAccessTokenPasses() { + String token = issueAccessToken(validTokenizer); + + validTokenizer.validateAccessToken(token); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java new file mode 100644 index 00000000..c89c4031 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java @@ -0,0 +1,278 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.dto.TokenResponseDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.user.service.UserService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 소셜 로그인 → 토큰 발급 → 저장 → 갱신까지 실제 MySQL 위에서 왕복시킨다. + * + *

프로덕션에서 관측된 1002(리프레시 토큰 정보를 찾을 수 없습니다) 는 + * "발급한 토큰이 DB 에 그대로 저장되지 않는" 경우에도 생긴다. + * refresh_token 컬럼이 varchar 이므로 토큰이 컬럼보다 길면 저장이 깨진다. + */ +@DisplayName("소셜 로그인·토큰 저장 통합") +class UserAuthServiceIntegrationTest extends IntegrationTestSupport { + + @Autowired + private UserAuthService userAuthService; + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private JwtTokenizer jwtTokenizer; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @PersistenceContext + private EntityManager entityManager; + + private static UserRegisterDto socialLogin(String identifier, String platform) { + return UserRegisterDto.builder() + .identifier(identifier) + .platform(platform) + .name("소셜사용자") + .email(identifier + "@test.ono") + .build(); + } + + private int refreshTokenColumnLength() { + Number length = (Number) entityManager.createNativeQuery(""" + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'refresh_token' + AND column_name = 'refresh_token' + """) + .getSingleResult(); + return length.intValue(); + } + + @Nested + @DisplayName("가입") + class SignUp { + + @Test + @DisplayName("게스트 가입은 사용자와 리프레시 토큰 세션을 함께 남긴다") + void guestSignUpPersistsUserAndSession() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + + assertThat(userRepository.count()).isEqualTo(1); + assertThat(refreshTokenRepository.findByRefreshToken(tokens.getRefreshToken())) + .isPresent() + .get() + .satisfies(session -> assertThat(session.getAuthority()).isEqualTo(Authority.ROLE_GUEST)); + } + + @ParameterizedTest(name = "{0} 소셜 로그인으로 가입한다") + @ValueSource(strings = {"GOOGLE", "APPLE", "KAKAO"}) + void memberSignUpPerPlatform(String platform) { + String identifier = platform.toLowerCase() + "-sub-abc123"; + + TokenResponseDto tokens = userAuthService.signUpMemberUser(socialLogin(identifier, platform)); + + assertThat(jwtTokenizer.getAuthorityFromRefreshToken(tokens.getRefreshToken())) + .isEqualTo(Authority.ROLE_MEMBER); + assertThat(userRepository.findByIdentifier(identifier)) + .isPresent() + .get() + .satisfies(user -> assertThat(user.getPlatform()).isEqualTo(platform)); + } + + @Test + @DisplayName("같은 소셜 계정으로 다시 로그인하면 계정을 새로 만들지 않는다") + void reloginKeepsSingleAccount() { + userAuthService.signUpMemberUser(socialLogin("google-sub-relogin", "GOOGLE")); + userAuthService.signUpMemberUser(socialLogin("google-sub-relogin", "GOOGLE")); + + assertThat(userRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("탈퇴한 사용자는 같은 소셜 계정으로 다시 가입할 수 있다") + void allowsRejoinAfterWithdrawal() { + userAuthService.signUpMemberUser(socialLogin("google-sub-rejoin", "GOOGLE")); + Long userId = userRepository.findByIdentifier("google-sub-rejoin").orElseThrow().getId(); + + userService.deleteUserById(userId); + + assertThatCode(() -> userAuthService.signUpMemberUser(socialLogin("google-sub-rejoin", "GOOGLE"))) + .as("탈퇴 후 재가입이 막히면 사용자는 영영 서비스를 못 쓴다") + .doesNotThrowAnyException(); + + User rejoined = userRepository.findByIdentifier("google-sub-rejoin").orElseThrow(); + assertThat(rejoined.getId()) + .as("재가입은 새 계정이어야 한다") + .isNotEqualTo(userId); + assertThat(userRepository.findById(userId)) + .as("탈퇴한 계정은 조회에서 빠진다") + .isEmpty(); + } + } + + @Nested + @DisplayName("리프레시 토큰 저장") + class RefreshTokenPersistence { + + @Test + @DisplayName("발급한 토큰이 refresh_token 컬럼에 잘리지 않고 그대로 저장된다") + void storesTokenWithoutTruncation() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + + String stored = (String) entityManager + .createNativeQuery("SELECT refresh_token FROM refresh_token WHERE user_id IS NOT NULL LIMIT 1") + .getSingleResult(); + + assertThat(stored) + .as("한 글자라도 잘리면 이후 갱신 요청이 전부 1002 가 된다") + .isEqualTo(tokens.getRefreshToken()); + } + + @Test + @DisplayName("발급 가능한 최대 토큰 길이가 refresh_token 컬럼 길이 안에 들어간다") + void tokenFitsInColumn() { + int columnLength = refreshTokenColumnLength(); + int longestToken = IntStream.range(0, 30) + .map(i -> jwtTokenizer.createRefreshToken( + String.valueOf(Long.MAX_VALUE), + java.util.Map.of("authority", Authority.ROLE_MEMBER)).length()) + .max() + .orElseThrow(); + + System.out.printf("refresh_token 컬럼=%d, 발급 토큰 최대 길이=%d, 여유=%d%n", + columnLength, longestToken, columnLength - longestToken); + + assertThat(longestToken) + .as("컬럼(%d)보다 긴 토큰은 저장되지 못하고 갱신이 1002 로 실패한다", columnLength) + .isLessThanOrEqualTo(columnLength); + assertThat(columnLength - longestToken) + .as("클레임이 하나만 늘어도 넘칠 만큼 여유가 없으면 안 된다") + .isGreaterThanOrEqualTo(64); + } + + @Test + @DisplayName("저장한 토큰 문자열로 세션을 다시 찾아 갱신까지 왕복한다") + void roundTripsThroughDatabase() { + TokenResponseDto issued = userAuthService.signUpGuestUser(); + + TokenResponseDto refreshed = userAuthService.refreshAccessToken( + new TokenRequestDto(issued.getAccessToken(), issued.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(refreshed.getRefreshToken())) + .as("회전된 새 토큰도 그대로 저장돼야 다음 갱신이 이어진다") + .isPresent(); + assertThat(refreshTokenRepository.findByRefreshToken(issued.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.count()) + .as("갱신은 세션 수를 늘리지 않고 회전만 시킨다") + .isEqualTo(1); + } + + @Test + @DisplayName("여러 기기에서 로그인하면 세션이 기기 수만큼 쌓이고 각자 갱신된다") + void keepsIndependentSessionsPerDevice() { + TokenResponseDto phone = userAuthService.signUpMemberUser(socialLogin("google-sub-devices", "GOOGLE")); + TokenResponseDto tablet = userAuthService.signUpMemberUser(socialLogin("google-sub-devices", "GOOGLE")); + + assertThat(refreshTokenRepository.count()).isEqualTo(2); + + userAuthService.refreshAccessToken(new TokenRequestDto(null, phone.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(tablet.getRefreshToken())) + .as("한 기기의 갱신이 다른 기기 세션을 무효화하면 안 된다") + .isPresent(); + } + + @Test + @DisplayName("로그아웃하면 세션 행이 사라진다") + void deletesSessionOnLogout() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(tokens.getRefreshToken()); + + jwtTokenService.logout(tokens.getAccessToken(), userId, tokens.getRefreshToken()); + + assertThat(refreshTokenRepository.findByRefreshToken(tokens.getRefreshToken())).isEmpty(); + } + } + + @Nested + @DisplayName("탈퇴 후 세션") + class WithdrawalSession { + + @Test + @DisplayName("탈퇴하면 남은 리프레시 토큰으로 갱신할 수 없다") + void rejectsRefreshAfterWithdrawal() { + TokenResponseDto tokens = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw", "GOOGLE")); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(tokens.getRefreshToken()); + + userService.deleteUserById(userId); + + assertThatThrownBy(() -> userAuthService.refreshAccessToken( + new TokenRequestDto(tokens.getAccessToken(), tokens.getRefreshToken()))) + .as("갱신이 성공하면 앱이 탈퇴한 계정으로 로그인 상태에 들어가 화면이 비어버린다") + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("탈퇴하면 기기별로 쌓인 세션 행이 모두 사라진다") + void removesEveryDeviceSessionOnWithdrawal() { + TokenResponseDto phone = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-devices", "GOOGLE")); + TokenResponseDto tablet = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-devices", "GOOGLE")); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(phone.getRefreshToken()); + assertThat(refreshTokenRepository.count()).isEqualTo(2); + + userService.deleteUserById(userId); + + assertThat(refreshTokenRepository.findByRefreshToken(phone.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.findByRefreshToken(tablet.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.count()) + .as("한 기기만 지우면 다른 기기가 계속 갱신에 성공한다") + .isZero(); + } + + @Test + @DisplayName("탈퇴는 다른 사용자의 세션을 건드리지 않는다") + void keepsOtherUserSession() { + TokenResponseDto leaver = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-me", "GOOGLE")); + TokenResponseDto stayer = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-other", "GOOGLE")); + + userService.deleteUserById(jwtTokenizer.getUserIdFromRefreshToken(leaver.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(stayer.getRefreshToken())).isPresent(); + assertThatCode(() -> userAuthService.refreshAccessToken( + new TokenRequestDto(stayer.getAccessToken(), stayer.getRefreshToken()))) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java new file mode 100644 index 00000000..56282ca8 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java @@ -0,0 +1,140 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.dto.TokenResponseDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.dto.UserResponseDto; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.time.LocalDateTime; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 가입 경로별로 부여되는 권한과, 인증 흐름의 위임 관계를 고정한다. + * + *

게스트에게 ROLE_MEMBER 가 새어나가면 게스트 계정으로 멤버 전용 경로가 열린다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserAuthService") +class UserAuthServiceTest { + + @Mock + private JwtTokenService jwtTokenService; + + @Mock + private UserService userService; + + @InjectMocks + private UserAuthService userAuthService; + + private static UserResponseDto userResponse(Long userId) { + return new UserResponseDto( + userId, "홍길동", "user@test.ono", null, + 1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 40L, + true, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("게스트 가입") + class SignUpGuest { + + @Test + @DisplayName("게스트 사용자를 만들고 ROLE_GUEST 토큰을 발급한다") + void issuesGuestAuthorityToken() { + given(userService.registerGuestUser()).willReturn(userResponse(10L)); + given(jwtTokenService.generateTokens(10L, Authority.ROLE_GUEST)) + .willReturn(new TokenResponseDto("Bearer access", "refresh")); + + TokenResponseDto response = userAuthService.signUpGuestUser(); + + assertThat(response.getAccessToken()).isEqualTo("Bearer access"); + verify(jwtTokenService).generateTokens(10L, Authority.ROLE_GUEST); + verify(jwtTokenService, never()).generateTokens(anyLong(), eq(Authority.ROLE_MEMBER)); + } + } + + @Nested + @DisplayName("멤버 가입") + class SignUpMember { + + @Test + @DisplayName("소셜 로그인 정보를 넘기면 ROLE_MEMBER 토큰을 발급한다") + void issuesMemberAuthorityToken() { + UserRegisterDto registerDto = UserRegisterDto.builder() + .email("member@test.ono") + .name("홍길동") + .identifier("google-sub-123") + .platform("GOOGLE") + .build(); + given(userService.registerMemberUser(registerDto)).willReturn(userResponse(20L)); + given(jwtTokenService.generateTokens(20L, Authority.ROLE_MEMBER)) + .willReturn(new TokenResponseDto("Bearer access", "refresh")); + + TokenResponseDto response = userAuthService.signUpMemberUser(registerDto); + + assertThat(response.getRefreshToken()).isEqualTo("refresh"); + verify(userService).registerMemberUser(registerDto); + verify(jwtTokenService).generateTokens(20L, Authority.ROLE_MEMBER); + } + + @Test + @DisplayName("사용자 등록이 실패하면 토큰을 발급하지 않는다") + void doesNotIssueTokenWhenRegistrationFails() { + UserRegisterDto registerDto = UserRegisterDto.builder().identifier("broken").build(); + willThrow(new ApplicationException(UserErrorCase.USER_NOT_FOUND)) + .given(userService).registerMemberUser(registerDto); + + assertThatThrownBy(() -> userAuthService.signUpMemberUser(registerDto)) + .isInstanceOf(ApplicationException.class); + + verify(jwtTokenService, never()).generateTokens(anyLong(), any(Authority.class)); + } + } + + @Nested + @DisplayName("갱신·로그아웃 위임") + class Delegation { + + @Test + @DisplayName("본문의 refreshToken 을 그대로 토큰 서비스에 넘긴다") + void passesRefreshTokenThrough() { + given(jwtTokenService.refreshAccessToken("refresh")) + .willReturn(new TokenResponseDto("Bearer new-access", "new-refresh")); + + TokenResponseDto response = userAuthService.refreshAccessToken( + new TokenRequestDto("Bearer old-access", "refresh")); + + assertThat(response.getAccessToken()).isEqualTo("Bearer new-access"); + verify(jwtTokenService).refreshAccessToken("refresh"); + } + + @Test + @DisplayName("로그아웃은 액세스 토큰·userId·리프레시 토큰을 그대로 전달한다") + void passesLogoutArgumentsThrough() { + userAuthService.logout("Bearer access", 30L, "refresh"); + + verify(jwtTokenService).logout("Bearer access", 30L, "refresh"); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java new file mode 100644 index 00000000..c4daaecc --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java @@ -0,0 +1,154 @@ +package com.aisip.OnO.backend.auth.token; + +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; +import com.aisip.OnO.backend.util.redis.RedisTokenService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.springframework.data.redis.RedisConnectionFailureException; + +import java.time.Duration; + +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.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * Redis 토큰 저장소의 키 규칙·TTL 계약과, Redis 장애 시 동작을 고정한다. + * + *

블랙리스트 조회는 인증 필터가 모든 인증 요청마다 호출한다. + * 여기서 예외가 새어나가면 Redis 장애가 곧 전체 사용자 인증 실패로 번진다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisTokenService") +class RedisTokenServiceTest { + + private static final Long USER_ID = 42L; + private static final String ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.payload.signature"; + + @Mock + private RedisSingleDataService redisSingleDataService; + + @InjectMocks + private RedisTokenService redisTokenService; + + @Nested + @DisplayName("리프레시 토큰 저장소") + class RefreshTokenStore { + + @Test + @DisplayName("RT: 프리픽스 + userId 키에 TTL 과 함께 저장한다") + void savesWithPrefixedKeyAndTtl() { + redisTokenService.saveRefreshToken(USER_ID, "refresh-token-value", 604_800L); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); + verify(redisSingleDataService).setSingleData(eq("RT:42"), eq("refresh-token-value"), ttlCaptor.capture()); + assertThat(ttlCaptor.getValue()).isEqualTo(Duration.ofSeconds(604_800L)); + } + + @Test + @DisplayName("저장된 토큰을 userId 로 되읽는다") + void readsBackByUserId() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn("refresh-token-value"); + + assertThat(redisTokenService.getRefreshToken(USER_ID)).isEqualTo("refresh-token-value"); + } + + @Test + @DisplayName("저장된 값이 없으면 빈 문자열이 아니라 null 을 돌려준다") + void returnsNullWhenAbsent() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn(""); + + assertThat(redisTokenService.getRefreshToken(USER_ID)) + .as("빈 문자열이 그대로 나가면 호출부가 '토큰 있음' 으로 오해한다") + .isNull(); + } + + @Test + @DisplayName("삭제는 같은 키 규칙으로 지운다") + void deletesWithSameKey() { + redisTokenService.deleteRefreshToken(USER_ID); + + verify(redisSingleDataService).deleteSingleData("RT:42"); + } + + @Test + @DisplayName("사용자마다 키가 분리되어 서로의 토큰을 덮어쓰지 않는다") + void isolatesKeysPerUser() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn("mine"); + given(redisSingleDataService.getSingleData("RT:43")).willReturn("theirs"); + + assertThat(redisTokenService.getRefreshToken(42L)).isEqualTo("mine"); + assertThat(redisTokenService.getRefreshToken(43L)).isEqualTo("theirs"); + } + } + + @Nested + @DisplayName("액세스 토큰 블랙리스트") + class Blacklist { + + @Test + @DisplayName("BL: 프리픽스 키에 남은 만료 시간만큼만 등록한다") + void addsWithRemainingTtl() { + redisTokenService.addToBlacklist(ACCESS_TOKEN, 1_200L); + + verify(redisSingleDataService) + .setSingleData("BL:" + ACCESS_TOKEN, "logout", Duration.ofSeconds(1_200L)); + } + + @Test + @DisplayName("등록된 토큰은 블랙리스트로 판정한다") + void detectsBlacklistedToken() { + given(redisSingleDataService.getSingleData("BL:" + ACCESS_TOKEN)).willReturn("logout"); + + assertThat(redisTokenService.isBlacklisted(ACCESS_TOKEN)).isTrue(); + } + + @Test + @DisplayName("등록되지 않은 토큰은 통과시킨다") + void allowsUnknownToken() { + given(redisSingleDataService.getSingleData(anyString())).willReturn(""); + + assertThat(redisTokenService.isBlacklisted(ACCESS_TOKEN)).isFalse(); + } + } + + @Nested + @DisplayName("Redis 장애") + class RedisOutage { + + /** + * 현재 구현은 조회 실패를 그대로 던진다. 인증 필터는 이 예외를 잡아 + * AUTHENTICATION_FAILED 로 처리하므로, Redis 가 죽으면 정상 토큰을 가진 + * 사용자도 401 을 받는다. 이 테스트는 그 동작을 명시적으로 못 박는다. + */ + @Test + @DisplayName("블랙리스트 조회 실패는 그대로 전파된다 — Redis 장애가 인증 실패로 번지는 지점") + void propagatesLookupFailure() { + given(redisSingleDataService.getSingleData(anyString())) + .willThrow(new RedisConnectionFailureException("redis down")); + + assertThatThrownBy(() -> redisTokenService.isBlacklisted(ACCESS_TOKEN)) + .isInstanceOf(RedisConnectionFailureException.class); + } + + @Test + @DisplayName("쓰기 실패는 예외 없이 흡수된다") + void swallowsWriteFailure() { + given(redisSingleDataService.setSingleData(anyString(), any(), any(Duration.class))).willReturn(0); + + redisTokenService.addToBlacklist(ACCESS_TOKEN, 60L); + + verify(redisSingleDataService).setSingleData(anyString(), any(), any(Duration.class)); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java new file mode 100644 index 00000000..84530162 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java @@ -0,0 +1,101 @@ +package com.aisip.OnO.backend.common.aop; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 서비스 계층에서 튀어나온 예외의 로깅 정책. + * + *

ApplicationException 은 의도된 4xx 라 로그를 남기지 않고, + * 요청 컨텍스트(traceId)가 있으면 전역 예외 핸들러가 ERROR 로 남기므로 여기서는 중복을 피한다. + */ +@DisplayName("서비스 예외 로깅 어드바이스") +class LoggingAspectTest { + + private LoggingAspect aspect; + private Logger aspectLogger; + private ListAppender appender; + + @BeforeEach + void setUp() { + MDC.clear(); + aspect = new LoggingAspect(); + aspectLogger = (Logger) LoggerFactory.getLogger(LoggingAspect.class); + appender = new ListAppender<>(); + appender.start(); + aspectLogger.addAppender(appender); + aspectLogger.setLevel(Level.DEBUG); + } + + @AfterEach + void tearDown() { + aspectLogger.detachAppender(appender); + appender.stop(); + MDC.clear(); + } + + @Test + @DisplayName("ApplicationException 은 의도된 흐름이라 아무것도 남기지 않는다") + void skipsApplicationException() { + aspect.logAfterThrowing(joinPoint(), new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + + assertThat(appender.list) + .as("4xx 로 응답할 예외까지 로그로 남기면 에러 로그가 의미를 잃는다") + .isEmpty(); + } + + @Test + @DisplayName("요청 컨텍스트가 있으면 전역 핸들러가 남기므로 DEBUG 로만 남긴다") + void logsDebugWhenInsideRequest() { + MDC.put("traceId", "trace-1234"); + + aspect.logAfterThrowing(joinPoint(), new IllegalStateException("boom")); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getFormattedMessage()) + .contains("UserService.findUser") + .contains("IllegalStateException"); + } + + @Test + @DisplayName("요청 컨텍스트 밖(배치/스케줄러)에서 터진 예외는 ERROR 로 스택과 함께 남긴다") + void logsErrorOutsideRequest() { + RuntimeException exception = new IllegalStateException("boom"); + + aspect.logAfterThrowing(joinPoint(), exception); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains("UserService.findUser"); + assertThat(event.getThrowableProxy()) + .as("배치 경로는 이 로그가 유일한 단서라 스택이 필요하다") + .isNotNull(); + } + + private JoinPoint joinPoint() { + Signature signature = mock(Signature.class); + when(signature.toShortString()).thenReturn("UserService.findUser(..)"); + JoinPoint joinPoint = mock(JoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + return joinPoint; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java b/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java new file mode 100644 index 00000000..83398972 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java @@ -0,0 +1,212 @@ +package com.aisip.OnO.backend.common.auth; + +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 인증/인가 실패 응답 계약. + * + *

{@link JwtTokenFilter} 가 남긴 실패 사유를 여기서 응답 바디로 옮긴다. + * 앱은 errorCode 로 "토큰을 갱신할지, 로그아웃시킬지"를 판단하므로 사유별 코드가 뭉개지면 + * 사용자가 갱신 가능한 상황에서도 로그아웃된다. + * + *

동시에 공개 경로(헬스체크, 로그인 페이지, 스웨거)에서는 아무것도 쓰지 않아야 한다. + * 여기서 401 JSON 을 써버리면 브라우저 화면 대신 JSON 이 뜨고, 헬스체크가 실패한다. + */ +@DisplayName("인증 실패 응답") +class AuthenticationFailureResponseTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final CustomAuthenticationEntryPoint entryPoint = new CustomAuthenticationEntryPoint(objectMapper); + private final CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler(objectMapper); + + private final AuthenticationException authenticationException = new BadCredentialsException("no credentials"); + + private MockHttpServletRequest request(String uri) { + return new MockHttpServletRequest("GET", uri); + } + + private JsonNode body(MockHttpServletResponse response) throws Exception { + return objectMapper.readTree(response.getContentAsString()); + } + + @Nested + @DisplayName("공개 경로") + class PublicPaths { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/actuator/health", + "/grafana", + "/grafana/d/board", + "/prometheus", + "/prometheus/metrics", + "/api/auth/signup/guest", + "/api/auth/signup/member", + "/api/auth/refresh", + "/", + "/robots.txt", + "/home", + "/login", + "/login?error", + "/swagger-ui/index.html", + "/v3/api-docs" + }) + @DisplayName("401 바디를 쓰지 않고 그대로 둔다") + void writesNothing(String uri) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request(uri), response, authenticationException); + + assertThat(response.getStatus()) + .as("%s 에서 상태코드를 바꾸면 로그인 화면/헬스체크가 깨진다", uri) + .isEqualTo(200); + assertThat(response.getContentAsString()).isEmpty(); + } + } + + @Nested + @DisplayName("보호 경로") + class ProtectedPaths { + + @Test + @DisplayName("실패 사유가 없으면 401 + 인증 실패(1007)로 응답한다") + void defaultsToAuthenticationFailed() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request("/api/problems"), response, authenticationException); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode()); + assertThat(body(response).get("message").asText()) + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED.getMessage()); + } + + @Test + @DisplayName("만료 사유가 전달되면 1005 로 응답한다 - 앱의 토큰 갱신 신호") + void keepsExpiredReason() throws Exception { + MockHttpServletRequest request = request("/api/problems"); + request.setAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, authenticationException); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .as("만료(1005)가 인증 실패(1007)로 뭉개지면 갱신 흐름이 끊긴다") + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode()); + } + + @Test + @DisplayName("로그아웃된 토큰 사유가 전달되면 1009 로 응답한다") + void keepsInvalidTokenReason() throws Exception { + MockHttpServletRequest request = request("/api/problems"); + request.setAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.INVALID_ACCESS_TOKEN); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, authenticationException); + + assertThat(body(response).get("errorCode").asInt()) + .isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode()); + } + + @Test + @DisplayName("ErrorCase 가 아닌 값이 들어 있으면 기본 인증 실패로 되돌린다") + void ignoresNonErrorCaseAttribute() throws Exception { + MockHttpServletRequest request = request("/api/problems"); + request.setAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE, "ACCESS_TOKEN_EXPIRED"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, authenticationException); + + assertThat(body(response).get("errorCode").asInt()) + .as("문자열이 들어와도 ClassCastException 으로 500 이 되면 안 된다") + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode()); + } + + @Test + @DisplayName("UTF-8 JSON 으로 응답한다") + void writesUtf8Json() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request("/api/users/me"), response, authenticationException); + + assertThat(response.getContentType()).isEqualTo("application/json;charset=UTF-8"); + assertThat(response.getContentAsString()) + .as("한글 메시지가 깨지면 앱이 그대로 노출한다") + .contains(AuthErrorCase.AUTHENTICATION_FAILED.getMessage()); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/api/auth/logout", + "/api/authorization-test", + "/apis/auth", + "/grafanas", + "/prometheus-admin", + "/swagger-uix", + "/loginx" + }) + @DisplayName("공개 경로와 접두사만 비슷한 경로는 401 로 막는다") + void doesNotTreatLookalikePathAsPublic(String uri) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request(uri), response, authenticationException); + + assertThat(response.getStatus()) + .as("%s 가 공개로 새면 인증 실패가 빈 200 으로 나간다", uri) + .isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode()); + } + + @Test + @DisplayName("로그아웃은 실패 사유를 그대로 실어 401 로 응답한다") + void logoutCarriesFailureReason() throws Exception { + MockHttpServletRequest request = request("/api/auth/logout"); + request.setAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, authenticationException); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .as("만료 사유가 살아야 앱이 갱신 후 다시 로그아웃을 시도한다") + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode()); + } + } + + @Nested + @DisplayName("권한 부족") + class AccessDenied { + + @Test + @DisplayName("403 + 접근 권한 없음(1008)으로 응답한다") + void returnsForbidden() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + accessDeniedHandler.handle(request("/admin/main"), response, + new AccessDeniedException("denied")); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentType()).isEqualTo("application/json;charset=UTF-8"); + assertThat(body(response).get("errorCode").asInt()) + .as("권한 부족(403)을 인증 실패(401)로 내리면 앱이 무한 재로그인에 빠진다") + .isEqualTo(AuthErrorCase.ACCESS_DENIED.getErrorCode()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java b/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java new file mode 100644 index 00000000..b87a74de --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java @@ -0,0 +1,407 @@ +package com.aisip.OnO.backend.common.auth; + +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.auth.service.JwtTokenizer; +import com.aisip.OnO.backend.util.redis.RedisTokenService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.MDC; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * JWT 인증 필터. + * + *

이 필터는 요청마다 "이 요청의 주체가 누구인가"를 정하고, 정할 수 없으면 그 이유를 + * {@link JwtTokenFilter#AUTH_ERROR_CASE_ATTRIBUTE} 로 넘겨 {@link CustomAuthenticationEntryPoint} + * 가 401 바디를 만들게 한다. 여기서 실패 사유를 뭉개면 프론트가 "토큰 만료 → 갱신" 흐름을 + * 타지 못하고 그대로 로그아웃된다. 그래서 사유별 분기를 각각 고정한다. + * + *

필터는 인증 실패 자체를 예외로 던지지 않고 항상 체인을 이어간다. 최종 거절은 시큐리티가 한다. + */ +@DisplayName("JWT 인증 필터") +class JwtTokenFilterTest { + + private static final String VALID_TOKEN = "valid.access.token"; + + private JwtTokenizer jwtTokenizer; + private RedisTokenService redisTokenService; + private JwtTokenFilter filter; + + @BeforeEach + void setUp() { + MDC.clear(); + SecurityContextHolder.clearContext(); + jwtTokenizer = mock(JwtTokenizer.class); + redisTokenService = mock(RedisTokenService.class); + filter = new JwtTokenFilter(jwtTokenizer, redisTokenService); + } + + @AfterEach + void tearDown() { + MDC.clear(); + SecurityContextHolder.clearContext(); + } + + private MockHttpServletRequest requestWith(String authorizationHeader) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/problems"); + if (authorizationHeader != null) { + request.addHeader(JwtTokenFilter.AUTHORIZATION_HEADER, authorizationHeader); + } + return request; + } + + private void givenValidToken(long userId, Authority authority) { + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn(String.valueOf(userId)); + given(claims.get("authority", String.class)).willReturn(authority.name()); + given(jwtTokenizer.getClaimsFromAccessToken(VALID_TOKEN)).willReturn(claims); + given(redisTokenService.isBlacklisted(VALID_TOKEN)).willReturn(false); + } + + private MockFilterChain doFilter(MockHttpServletRequest request) throws Exception { + MockFilterChain chain = new MockFilterChain(); + filter.doFilter(request, new MockHttpServletResponse(), chain); + return chain; + } + + private Object errorAttribute(MockHttpServletRequest request) { + return request.getAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE); + } + + @Nested + @DisplayName("필터를 타지 않는 경로") + class SkippedPaths { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/actuator/health", + "/management/actuator/prometheus", + "/grafana", + "/grafana/d/abc", + "/prometheus", + "/prometheus/metrics", + "/", + "/robots.txt", + "/home", + "/images/logo.png", + "/perform-login", + "/login", + "/css/main.css", + "/js/app.js", + "/swagger-ui/index.html", + "/v3/api-docs/swagger-config" + }) + @DisplayName("공개 경로는 토큰 파싱 없이 통과시킨다") + void skipsPublicPaths(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", path); + + assertThat(filter.shouldNotFilter(request)) + .as("공개 경로 %s 에 토큰 검증을 걸면 헬스체크/문서가 401 이 된다", path) + .isTrue(); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/api/problems", + "/api/auth/refresh", + "/admin/main", + "/loginx", + "/homepage", + "/image/1.png", + "/swagger-ui", + "/v3/api-docs" + }) + @DisplayName("보호 경로는 필터를 탄다 - 접두사만 비슷한 경로가 새어 나가면 안 된다") + void filtersProtectedPaths(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", path); + + assertThat(filter.shouldNotFilter(request)) + .as("%s 는 인증 검사를 받아야 한다", path) + .isFalse(); + } + } + + @Nested + @DisplayName("Authorization 헤더 해석") + class HeaderParsing { + + @Test + @DisplayName("헤더가 없으면 토큰 검증 없이 익명으로 통과한다") + void passesThroughWithoutHeader() throws Exception { + MockHttpServletRequest request = requestWith(null); + + MockFilterChain chain = doFilter(request); + + assertThat(chain.getRequest()).as("체인은 항상 이어져야 한다").isNotNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + assertThat(errorAttribute(request)) + .as("헤더 자체가 없는 건 '실패'가 아니라 '익명'이다") + .isNull(); + verifyNoInteractions(jwtTokenizer, redisTokenService); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"Basic abcdef", "bearer lower-case", "Bearer", "abc.def.ghi"}) + @DisplayName("Bearer 형식이 아니면 토큰으로 보지 않는다") + void ignoresNonBearerHeader(String header) throws Exception { + MockHttpServletRequest request = requestWith(header); + + doFilter(request); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verifyNoInteractions(jwtTokenizer, redisTokenService); + } + + @Test + @DisplayName("Bearer 뒤 공백은 잘라내고 검증한다") + void trimsTokenValue() throws Exception { + givenValidToken(42L, Authority.ROLE_MEMBER); + + doFilter(requestWith("Bearer " + VALID_TOKEN + " ")); + + verify(jwtTokenizer).validateAccessToken(VALID_TOKEN); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()) + .isEqualTo(42L); + } + } + + @Nested + @DisplayName("유효한 토큰") + class ValidToken { + + @Test + @DisplayName("userId 를 principal 로, authority 를 권한으로 세운다") + void setsAuthentication() throws Exception { + givenValidToken(77L, Authority.ROLE_ADMIN); + + doFilter(requestWith("Bearer " + VALID_TOKEN)); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()) + .as("모든 소유권 검증이 이 principal 을 userId 로 신뢰한다") + .isEqualTo(77L); + assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .containsExactly("ROLE_ADMIN"); + } + + @Test + @DisplayName("MDC 에 userId 와 authority 를 남긴다") + void putsUserIdAndAuthorityIntoMdc() throws Exception { + givenValidToken(77L, Authority.ROLE_GUEST); + + doFilter(requestWith("Bearer " + VALID_TOKEN)); + + assertThat(MDC.get("userId")).isEqualTo("77"); + assertThat(MDC.get("authority")).isEqualTo("ROLE_GUEST"); + } + + @Test + @DisplayName("만료 검증을 블랙리스트 조회보다 먼저 한다") + void validatesBeforeBlacklistLookup() throws Exception { + givenValidToken(1L, Authority.ROLE_MEMBER); + + doFilter(requestWith("Bearer " + VALID_TOKEN)); + + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(jwtTokenizer, redisTokenService); + inOrder.verify(jwtTokenizer).validateAccessToken(VALID_TOKEN); + inOrder.verify(redisTokenService).isBlacklisted(VALID_TOKEN); + } + + @Test + @DisplayName("인증에 성공하면 에러 사유를 남기지 않는다") + void leavesNoErrorAttribute() throws Exception { + givenValidToken(1L, Authority.ROLE_MEMBER); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isNull(); + } + } + + @Nested + @DisplayName("거절 사유 구분") + class RejectionReasons { + + @Test + @DisplayName("로그아웃된(블랙리스트) 토큰은 INVALID_ACCESS_TOKEN 이다") + void marksBlacklistedToken() throws Exception { + given(redisTokenService.isBlacklisted(VALID_TOKEN)).willReturn(true); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .as("블랙리스트 토큰으로 인증되면 로그아웃이 무의미해진다") + .isNull(); + verify(jwtTokenizer, never()).getClaimsFromAccessToken(anyString()); + } + + @Test + @DisplayName("블랙리스트로 걸러도 체인은 계속 이어진다") + void continuesChainForBlacklistedToken() throws Exception { + given(redisTokenService.isBlacklisted(VALID_TOKEN)).willReturn(true); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + MockFilterChain chain = doFilter(request); + + assertThat(chain.getRequest()).isNotNull(); + } + + /** + * 탈퇴한 계정의 토큰은 서명도 만료도 멀쩡하다. 토큰 문자열 블랙리스트는 탈퇴 요청에 실린 + * 그 한 장밖에 못 막으므로, 사용자 단위로 한 번 더 본다. 이게 없으면 사용자 존재를 확인하지 + * 않는 엔드포인트(POST /api/fcm/token)가 최대 30분간 200 으로 동작한다. (#300) + */ + @Test + @DisplayName("탈퇴한 사용자의 토큰은 INVALID_ACCESS_TOKEN 이다") + void marksWithdrawnUserToken() throws Exception { + givenValidToken(9L, Authority.ROLE_MEMBER); + given(redisTokenService.isUserBlacklisted(9L)).willReturn(true); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + MockFilterChain chain = doFilter(request); + + assertThat(errorAttribute(request)).isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .as("탈퇴한 계정으로 인증되면 지운 데이터가 되살아난다") + .isNull(); + assertThat(chain.getRequest()).as("체인은 항상 이어져야 한다").isNotNull(); + } + + @Test + @DisplayName("탈퇴하지 않은 사용자는 그대로 인증된다") + void keepsAuthenticationForLivingUser() throws Exception { + givenValidToken(9L, Authority.ROLE_MEMBER); + given(redisTokenService.isUserBlacklisted(9L)).willReturn(false); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()) + .isEqualTo(9L); + } + + @Test + @DisplayName("만료된 토큰은 ACCESS_TOKEN_EXPIRED 다 - 프론트의 갱신 트리거") + void marksExpiredToken() throws Exception { + // JwtTokenizer 는 만료를 ApplicationException(ACCESS_TOKEN_EXPIRED) 로 감싸 던진다. + // 필터는 그 errorCase 를 그대로 전달한다. + willThrow(new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED)) + .given(jwtTokenizer).validateAccessToken(VALID_TOKEN); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)) + .as("만료를 일반 인증 실패로 뭉개면 앱이 토큰 갱신 대신 로그아웃한다") + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verifyNoInteractions(redisTokenService); + } + + @Test + @DisplayName("서명이 깨진 토큰은 INVALID_ACCESS_TOKEN 이다") + void marksMalformedToken() throws Exception { + // 만료가 아닌 검증 실패는 INVALID_ACCESS_TOKEN 이다. + // 만료(1005)와 갈라놔야 프론트가 갱신할 이유 없는 토큰에 갱신을 걸지 않는다. + willThrow(new ApplicationException(AuthErrorCase.INVALID_ACCESS_TOKEN)) + .given(jwtTokenizer).validateAccessToken(VALID_TOKEN); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("예상 못 한 예외는 AUTHENTICATION_FAILED 로 떨어뜨린다") + void marksUnexpectedFailure() throws Exception { + willThrow(new JwtException("signature mismatch")) + .given(jwtTokenizer).validateAccessToken(VALID_TOKEN); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)) + .as("ErrorCase 를 못 정하는 예외까지 500 으로 새면 안 된다") + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("subject 가 숫자가 아니면 500 이 아니라 인증 실패로 처리한다") + void marksNonNumericSubject() throws Exception { + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn("not-a-number"); + given(jwtTokenizer.getClaimsFromAccessToken(VALID_TOKEN)).willReturn(claims); + given(redisTokenService.isBlacklisted(VALID_TOKEN)).willReturn(false); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("모르는 권한 값이면 인증 실패로 처리한다") + void marksUnknownAuthority() throws Exception { + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn("1"); + given(claims.get("authority", String.class)).willReturn("ROLE_SUPERUSER"); + given(jwtTokenizer.getClaimsFromAccessToken(VALID_TOKEN)).willReturn(claims); + given(redisTokenService.isBlacklisted(VALID_TOKEN)).willReturn(false); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)) + .as("토큰에 없는 권한이 들어와도 승격되거나 500 이 나면 안 된다") + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("어떤 실패에서도 예외를 던지지 않고 체인을 이어간다") + void neverThrows() throws Exception { + willThrow(new JwtException("broken")) + .given(jwtTokenizer).validateAccessToken(VALID_TOKEN); + + MockFilterChain chain = doFilter(requestWith("Bearer " + VALID_TOKEN)); + + assertThat(chain.getRequest()) + .as("필터가 예외를 던지면 401 바디 대신 500 이 나간다") + .isNotNull(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java new file mode 100644 index 00000000..58f92e96 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java @@ -0,0 +1,99 @@ +package com.aisip.OnO.backend.common.emoji; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 앱이 보내는 이모지 키 화이트리스트 검증. + * + *

허용 목록에 없는 키가 통과하면 앱이 렌더링할 에셋을 찾지 못하므로 서버에서 막는다. + */ +@DisplayName("커스텀 이모지 검증") +class CustomEmojiValidatorTest { + + private final CustomEmojiValidator validator = new CustomEmojiValidator(); + + @Nested + @DisplayName("허용된 키") + class AllowedKey { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"happy_tears", "studying_together", "trophy_celebration", "wearing_scarf"}) + @DisplayName("화이트리스트에 있으면 통과한다") + void passesWhitelistedKey(String emojiKey) { + assertThat(validator.isAllowed(emojiKey)).isTrue(); + assertThatCode(() -> validator.validate(emojiKey)).doesNotThrowAnyException(); + } + + // 앱 목록에 늦게 들어온 넷이다. 서버 목록에 빠져 있어 고르면 저장이 11001 로 실패했다 (OnO_FRONT#232). + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"angry_on_fire", "christmas_santa", "excited_sparkles", "peeking_pole"}) + @DisplayName("앱 목록에 새로 들어온 키도 통과한다") + void passesKeyAddedToAppCatalog(String emojiKey) { + assertThat(validator.isAllowed(emojiKey)).isTrue(); + assertThatCode(() -> validator.validate(emojiKey)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("허용되지 않은 키") + class DisallowedKey { + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"unknown_emoji", "HAPPY_TEARS", " happy_tears", "happy_tears ", "😀", "