diff --git a/deploy/prod/grafana/provisioning/alerting/contact-points.yml b/deploy/prod/grafana/provisioning/alerting/contact-points.yml index 2825e9a8..2acbfb61 100644 --- a/deploy/prod/grafana/provisioning/alerting/contact-points.yml +++ b/deploy/prod/grafana/provisioning/alerting/contact-points.yml @@ -10,15 +10,59 @@ contactPoints: settings: url: $DISCORD_MONITOR_WEBHOOK_URL use_discord_username: false - title: '{{ if eq .Status "firing" }}{{ else }}[복구] {{ end }} {{ .CommonLabels.alertname }}' + title: '{{ if eq .CommonLabels.alertname "[CRITICAL] 5xx 에러율 1% 초과" }}{{ if eq .Status "firing" }}{{ .CommonLabels.alertname }}{{ else }}[RESOLVED] 5xx 에러율 정상화{{ end }}{{ else }}{{ if eq .Status "firing" }}{{ else }}[복구] {{ end }}{{ .CommonLabels.alertname }}{{ end }}' message: | + {{ if eq .CommonLabels.alertname "[CRITICAL] 5xx 에러율 1% 초과" }} + {{ $notificationStatus := .Status }} {{ range .Alerts }} + {{ if eq $notificationStatus "firing" }} + Time : {{ .StartsAt | tz "Asia/Seoul" | date "2006-01-02 15:04:05" }} (KST) + 5xx Rate : {{ .Annotations.error_rate }} + + ──────────────────────── + {{ .Annotations.summary }} - + 확인 필요 {{ .Annotations.description }} - + + ──────────────────────── + + Explore Logs + {{ .Annotations.explore_url }} + + Alert + {{ .GeneratorURL }} + + Dashboard + {{ .DashboardURL }} + {{ else }} + Detected : {{ .StartsAt | tz "Asia/Seoul" | date "2006-01-02 15:04:05" }} (KST) + Resolved : {{ .EndsAt | tz "Asia/Seoul" | date "2006-01-02 15:04:05" }} (KST) + Duration : {{ .EndsAt.Sub .StartsAt }} + + 최근 5분 기준 5xx 에러율이 1% 이하로 내려갔습니다. + + ──────────────────────── + + Explore Logs + {{ .Annotations.explore_url }} + + Alert + {{ .GeneratorURL }} + + Dashboard + {{ .DashboardURL }} + {{ end }} + {{ end }} + {{ else }} + {{ range .Alerts }} + {{ .Annotations.summary }} + + 확인 필요 + {{ .Annotations.description }} + 관련 모니터링 링크 - Grafana Alert: {{ .GeneratorURL }} {{ end }} - + {{ end }} diff --git a/deploy/prod/grafana/provisioning/alerting/rules.yml b/deploy/prod/grafana/provisioning/alerting/rules.yml index f15483f8..32b08f46 100644 --- a/deploy/prod/grafana/provisioning/alerting/rules.yml +++ b/deploy/prod/grafana/provisioning/alerting/rules.yml @@ -1,5 +1,9 @@ apiVersion: 1 +deleteRules: + - orgId: 1 + uid: didit-http-5xx-detected + groups: - orgId: 1 name: didit-prod-critical @@ -69,74 +73,6 @@ groups: - 최근 트래픽 증가 여부 - 메모리 누수 및 OOM 로그 - - uid: didit-http-5xx-detected - title: '[CRITICAL] 5xx 에러 발생' - condition: C - data: - - refId: A - datasourceUid: prometheus - relativeTimeRange: - from: 120 - to: 0 - model: - refId: A - expr: | - sum( - increase( - http_server_requests_seconds_count{ - environment="prod", - status=~"5.." - }[1m] - ) - ) - instant: true - intervalMs: 1000 - maxDataPoints: 43200 - - - refId: B - datasourceUid: __expr__ - model: - refId: B - type: reduce - expression: A - reducer: last - datasource: - type: __expr__ - uid: __expr__ - - - refId: C - datasourceUid: __expr__ - model: - refId: C - type: threshold - expression: B - conditions: - - evaluator: - type: gt - params: [ 0 ] - operator: - type: and - query: - params: [ C ] - reducer: - type: last - type: query - datasource: - type: __expr__ - uid: __expr__ - - noDataState: OK - execErrState: Alerting - for: 0s - labels: - severity: critical - annotations: - summary: 5xx 에러가 발생했습니다. - description: | - - API 서버 ERROR 로그 - - 발생 API 및 예외 내용 - - DB 및 외부 연동 서비스 상태 - - uid: didit-http-5xx-rate-critical title: '[CRITICAL] 5xx 에러율 1% 초과' condition: C @@ -204,14 +140,19 @@ groups: noDataState: OK execErrState: Alerting for: 2m + dashboardUid: dfp8jkcluca2oe + labels: severity: critical + annotations: summary: 5xx 에러율이 1%를 초과했습니다. + error_rate: '{{ humanizePercentage $values.B.Value }}' description: | - API 서버 로그 - 최근 배포 여부 - 특정 API 및 외부 서비스 장애 여부 + explore_url: https://monitoring.didit.ai.kr/explore?schemaVersion=1&panes=%7B%22ttc%22:%7B%22datasource%22:%22loki%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bcontainer%3D~%5C%22didit-api-%28blue%7Cgreen%29%5C%22%7D%20%7C%3D%20%5C%22ERROR%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22loki%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-15m%22,%22to%22:%22now%22%7D,%22panelsState%22:%7B%22logs%22:%7B%22columns%22:%7B%220%22:%22Time%22,%221%22:%22Line%22%7D,%22visualisationType%22:%22table%22,%22labelFieldName%22:%22labels%22%7D%7D%7D%7D&orgId=1 - uid: didit-hikaricp-usage-critical title: '[CRITICAL] HikariCP 커넥션 풀 사용률 90% 초과' diff --git a/src/docs/asciidoc/app/index.adoc b/src/docs/asciidoc/app/index.adoc index 91f5bfbd..4c8f3df8 100644 --- a/src/docs/asciidoc/app/index.adoc +++ b/src/docs/asciidoc/app/index.adoc @@ -19,6 +19,7 @@ include::auth.adoc[] include::user.adoc[] include::retrospect.adoc[] include::badge.adoc[] +include::mission.adoc[] include::notice.adoc[] include::inquiry.adoc[] include::project.adoc[] diff --git a/src/docs/asciidoc/app/mission.adoc b/src/docs/asciidoc/app/mission.adoc new file mode 100644 index 00000000..ff2f0bd3 --- /dev/null +++ b/src/docs/asciidoc/app/mission.adoc @@ -0,0 +1,32 @@ += 미션 API + +== 현재 미션 조회 + +=== Request + +include::{snippets}/mission/current/http-request.adoc[] + +=== Response + +include::{snippets}/mission/current/http-response.adoc[] +include::{snippets}/mission/current/response-fields.adoc[] + +== 레벨업 팝업 확인 + +=== Request + +include::{snippets}/mission/level-up-confirm/http-request.adoc[] + +=== Response + +include::{snippets}/mission/level-up-confirm/http-response.adoc[] + +== 실패 팝업 확인 및 재도전 + +=== Request + +include::{snippets}/mission/retry/http-request.adoc[] + +=== Response + +include::{snippets}/mission/retry/http-response.adoc[] diff --git a/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt new file mode 100644 index 00000000..5f78223b --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/integration/scheduler/MissionScheduler.kt @@ -0,0 +1,63 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.shared.ServiceTime +import jakarta.transaction.Transactional +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.temporal.TemporalAdjusters + +@Component +class MissionScheduler( + private val userMissionRepository: UserMissionRepository, + private val missionRepository: MissionRepository, + private val retrospectiveRepository: RetrospectiveRepository, +) { + companion object { + private val logger = LoggerFactory.getLogger(MissionScheduler::class.java) + } + + @Scheduled(cron = "0 0 * * * *") + @Transactional + fun checkExpiredLv2Missions() { + val expiredMissions = userMissionRepository.findExpiredLv2Missions() + + expiredMissions.forEach { userMission -> + userMission.setFailureWaitingConfirm() + userMissionRepository.save(userMission) + } + + logger.info("Lv2 만료 미션 검사 완료 - count: ${expiredMissions.size}") + } + + @Scheduled(cron = "0 0 0 ? * MON") + @Transactional + fun checkConsecutiveWeekFailure() { + val consecutiveWeekMissions = userMissionRepository.findConsecutiveWeekMissionsInProgress() + + consecutiveWeekMissions.forEach { userMission -> + val lastWeekStart = + ServiceTime.today().with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)).minusWeeks(1) + + val lastWeekRetroCount = + retrospectiveRepository.countCompletedByUserIdAndPeriod( + userId = userMission.userId, + from = ServiceTime.startOfDayUtc(lastWeekStart), + to = ServiceTime.startOfDayUtc(lastWeekStart.plusWeeks(1)), + ) + + if (lastWeekRetroCount == 0) { + userMission.setFailureWaitingConfirm() + userMissionRepository.save(userMission) + logger.info( + "연속주차 실패 - userId: ${userMission.userId}, missionId: ${userMission.missionId}", + ) + } + } + + logger.info("연속주차 실패 검사 완료 - total: ${consecutiveWeekMissions.size}") + } +} diff --git a/src/main/kotlin/com/didit/adapter/persistence/achievement/OrganizationAchievementReaderImpl.kt b/src/main/kotlin/com/didit/adapter/persistence/achievement/OrganizationAchievementReaderImpl.kt new file mode 100644 index 00000000..7a0d7aa8 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/persistence/achievement/OrganizationAchievementReaderImpl.kt @@ -0,0 +1,30 @@ +package com.didit.adapter.persistence.achievement + +import com.didit.application.achievement.required.OrganizationAchievementReader +import com.didit.application.organization.required.ProjectRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class OrganizationAchievementReaderImpl( + private val projectRepository: ProjectRepository, + private val retrospectiveRepository: RetrospectiveRepository, +) : OrganizationAchievementReader { + override fun countProjects(userId: UUID): Int = projectRepository.findAllByUserIdAndDeletedAtIsNull(userId).size + + override fun countProjectAssignedRetros(userId: UUID): Int = + retrospectiveRepository + .findAllCompletedByUserId(userId) + .count { it.projectId != null } + + override fun maxRetroCountInOneProject(userId: UUID): Int = + retrospectiveRepository + .findAllCompletedByUserId(userId) + .mapNotNull { it.projectId } + .groupingBy { it } + .eachCount() + .values + .maxOrNull() + ?: 0 +} diff --git a/src/main/kotlin/com/didit/adapter/persistence/achievement/RetrospectAchievementReaderImpl.kt b/src/main/kotlin/com/didit/adapter/persistence/achievement/RetrospectAchievementReaderImpl.kt index 15cc7468..f446ae03 100644 --- a/src/main/kotlin/com/didit/adapter/persistence/achievement/RetrospectAchievementReaderImpl.kt +++ b/src/main/kotlin/com/didit/adapter/persistence/achievement/RetrospectAchievementReaderImpl.kt @@ -4,27 +4,53 @@ import com.didit.application.achievement.required.RetrospectAchievementReader import com.didit.application.retrospect.required.RetrospectiveRepository import com.didit.domain.retrospect.RetroStatus import com.didit.domain.shared.ServiceTime +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime import java.util.UUID @Component class RetrospectAchievementReaderImpl( private val retrospectiveRepository: RetrospectiveRepository, + @Value("\${achievement.feature-launched-at:2026-06-30T00:00:00}") private val featureLaunchedAt: LocalDateTime, ) : RetrospectAchievementReader { - override fun countCompletedRetros(userId: UUID): Int = - retrospectiveRepository.countByUserIdAndStatusAndDeletedAtIsNull(userId, RetroStatus.COMPLETED) + companion object { + private const val MAX_CONSECUTIVE_WEEKS_SCAN = 60 + } - override fun countDeepQuestionAnswers(userId: UUID): Int = - retrospectiveRepository - .findAllCompletedByUserId(userId) - .sumOf { it.countDeepQuestionAnswers() } + override fun countCompletedRetros(userId: UUID): Int = completedAtsSinceLaunch(userId).size + + override fun countRetrosInWeek( + userId: UUID, + weekStartKst: LocalDate, + ): Int = weekCountsByMondayKst(userId)[weekStartKst] ?: 0 + + override fun countConsecutiveWeeksWithMinRetros( + userId: UUID, + weeklyMinCount: Int, + ): Int { + val weekCounts = weekCountsByMondayKst(userId) + var weekStart = ServiceTime.today().with(DayOfWeek.MONDAY) + var consecutive = 0 + repeat(MAX_CONSECUTIVE_WEEKS_SCAN) { + val count = weekCounts[weekStart] ?: 0 + if (count < weeklyMinCount) return consecutive + consecutive += 1 + weekStart = weekStart.minusWeeks(1) + } + return consecutive + } - override fun countWeeklyGoalAchievedWeeks(userId: UUID): Int = + private fun weekCountsByMondayKst(userId: UUID): Map = + completedAtsSinceLaunch(userId) + .map { ServiceTime.toServiceDate(it) } + .groupingBy { it.with(DayOfWeek.MONDAY) } + .eachCount() + + private fun completedAtsSinceLaunch(userId: UUID): List = retrospectiveRepository .findCompletedAtByUserIdAndStatusAndDeletedAtIsNull(userId, RetroStatus.COMPLETED) - .map { ServiceTime.toServiceDate(it) } - .distinct() - .groupBy { it.with(DayOfWeek.MONDAY) } - .count { (_, dates) -> dates.size >= 3 } + .filter { !it.isBefore(featureLaunchedAt) } } diff --git a/src/main/kotlin/com/didit/adapter/webapi/achievement/BadgeApi.kt b/src/main/kotlin/com/didit/adapter/webapi/achievement/BadgeApi.kt index fa647b02..215dea26 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/achievement/BadgeApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/achievement/BadgeApi.kt @@ -21,6 +21,12 @@ class BadgeApi( @CurrentUserId userId: UUID, ): SuccessResponse> = SuccessResponse.of(badgeFinder.findAll(userId)) + @RequireAuth + @GetMapping("/recent") + fun findRecent( + @CurrentUserId userId: UUID, + ): SuccessResponse> = SuccessResponse.of(badgeFinder.findRecent(userId)) + @RequireAuth @GetMapping("/popup") fun findUnnotified( diff --git a/src/main/kotlin/com/didit/adapter/webapi/achievement/MissionApi.kt b/src/main/kotlin/com/didit/adapter/webapi/achievement/MissionApi.kt new file mode 100644 index 00000000..926ccbc2 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/achievement/MissionApi.kt @@ -0,0 +1,49 @@ +package com.didit.adapter.webapi.achievement + +import com.didit.adapter.webapi.auth.annotation.CurrentUserId +import com.didit.adapter.webapi.auth.annotation.RequireAuth +import com.didit.adapter.webapi.response.SuccessResponse +import com.didit.application.achievement.dto.CurrentMissionResponse +import com.didit.application.achievement.provided.MissionFinder +import com.didit.application.achievement.provided.UserMissionRegister +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RequestMapping("/api/v1/missions") +@RestController +class MissionApi( + private val missionFinder: MissionFinder, + private val userMissionRegister: UserMissionRegister, +) { + @RequireAuth + @GetMapping("/current") + fun getCurrentMission( + @CurrentUserId userId: UUID, + ): SuccessResponse { + userMissionRegister.ensureInitialized(userId) + return SuccessResponse.of(missionFinder.getCurrentMission(userId)) + } + + @RequireAuth + @ResponseStatus(HttpStatus.NO_CONTENT) + @PostMapping("/level-up/confirm") + fun confirmLevelUp( + @CurrentUserId userId: UUID, + ) { + userMissionRegister.confirmLevelUp(userId) + } + + @RequireAuth + @ResponseStatus(HttpStatus.NO_CONTENT) + @PostMapping("/retry") + fun retryMission( + @CurrentUserId userId: UUID, + ) { + userMissionRegister.retryMission(userId) + } +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/CurrentMissionResponse.kt b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/CurrentMissionResponse.kt new file mode 100644 index 00000000..c24c50d7 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/CurrentMissionResponse.kt @@ -0,0 +1,47 @@ +package com.didit.adapter.webapi.achievement.dto + +import com.didit.domain.achievement.Mission +import com.didit.domain.achievement.UserLevel +import com.didit.domain.achievement.UserMission +import java.util.UUID + +data class CurrentMissionResponse( + val currentLevel: Int, + val missionId: UUID, + val missionType: String, + val title: String, + val description: String, + val subText: String?, + val status: String, + val progress: Int, + val targetCount: Int, + val daysRemaining: Int? = null, + val weeklyRetroStatus: WeeklyRetroStatus? = null, + val isLevelUpPopupShown: Boolean = false, + val isFailurePopupShown: Boolean = false, +) { + companion object { + fun from( + userLevel: UserLevel, + userMission: UserMission, + mission: Mission, + daysRemaining: Int? = null, + weeklyRetroStatus: WeeklyRetroStatus? = null, + ): CurrentMissionResponse = + CurrentMissionResponse( + currentLevel = userLevel.currentLevel, + missionId = userMission.id, + missionType = mission.missionType.name, + title = mission.title, + description = mission.description, + subText = mission.subText, + status = userMission.status.name, + progress = userMission.progress, + targetCount = mission.targetCount, + daysRemaining = daysRemaining, + weeklyRetroStatus = weeklyRetroStatus, + isLevelUpPopupShown = userMission.levelUpPopupShown, + isFailurePopupShown = userMission.failurePopupShown, + ) + } +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeekDayStatus.kt b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeekDayStatus.kt new file mode 100644 index 00000000..0a7bad6a --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeekDayStatus.kt @@ -0,0 +1,6 @@ +package com.didit.adapter.webapi.achievement.dto + +data class WeekDayStatus( + val day: String, + val isCompleted: Boolean, +) diff --git a/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeeklyRetroStatus.kt b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeeklyRetroStatus.kt new file mode 100644 index 00000000..3bba5f30 --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/achievement/dto/WeeklyRetroStatus.kt @@ -0,0 +1,21 @@ +package com.didit.adapter.webapi.achievement.dto + +data class WeeklyRetroStatus( + val weekDays: List, +) { + companion object { + fun empty(): WeeklyRetroStatus = + WeeklyRetroStatus( + weekDays = + listOf( + WeekDayStatus("월", false), + WeekDayStatus("화", false), + WeekDayStatus("수", false), + WeekDayStatus("목", false), + WeekDayStatus("금", false), + WeekDayStatus("토", false), + WeekDayStatus("일", false), + ), + ) + } +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApi.kt b/src/main/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApi.kt new file mode 100644 index 00000000..04a729ed --- /dev/null +++ b/src/main/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApi.kt @@ -0,0 +1,29 @@ +package com.didit.adapter.webapi.admin + +import com.didit.adapter.webapi.admin.annotation.RequireAdmin +import com.didit.adapter.webapi.response.SuccessResponse +import com.didit.application.admin.provided.AdminAchievementStatsFinder +import com.didit.application.admin.provided.AdminBadgeStat +import com.didit.application.admin.provided.AdminLevelStat +import com.didit.application.admin.provided.AdminMissionStat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/api/v1/admin/stats") +@RestController +class AdminAchievementStatsApi( + private val adminAchievementStatsFinder: AdminAchievementStatsFinder, +) { + @RequireAdmin + @GetMapping("/levels") + fun getLevelStats(): SuccessResponse> = SuccessResponse.of(adminAchievementStatsFinder.getLevelStats()) + + @RequireAdmin + @GetMapping("/missions") + fun getMissionStats(): SuccessResponse> = SuccessResponse.of(adminAchievementStatsFinder.getMissionStats()) + + @RequireAdmin + @GetMapping("/badges") + fun getBadgeStats(): SuccessResponse> = SuccessResponse.of(adminAchievementStatsFinder.getBadgeStats()) +} diff --git a/src/main/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApi.kt b/src/main/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApi.kt index 6c4f0d5a..e2754e52 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApi.kt @@ -1,12 +1,24 @@ package com.didit.adapter.webapi.admin +import com.didit.adapter.webapi.admin.annotation.CurrentAdminId import com.didit.adapter.webapi.admin.annotation.RequireAdmin import com.didit.adapter.webapi.response.SuccessResponse +import com.didit.application.admin.provided.AdminBadgeCreateCommand import com.didit.application.admin.provided.AdminBadgeFinder import com.didit.application.admin.provided.AdminBadgeHolder +import com.didit.application.admin.provided.AdminBadgeMetaResult +import com.didit.application.admin.provided.AdminBadgeRegister import com.didit.application.admin.provided.AdminBadgeResult +import com.didit.application.admin.provided.AdminBadgeUpdateCommand +import com.didit.application.audit.ActorType +import com.didit.application.audit.AuditAction +import com.didit.application.audit.AuditLogger import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import java.util.UUID @@ -15,14 +27,134 @@ import java.util.UUID @RestController class AdminBadgeApi( private val adminBadgeFinder: AdminBadgeFinder, + private val adminBadgeRegister: AdminBadgeRegister, + private val auditLogger: AuditLogger, ) { @RequireAdmin @GetMapping fun findAll(): SuccessResponse> = SuccessResponse.of(adminBadgeFinder.findAll()) + @RequireAdmin + @GetMapping("/condition-types") + fun findMeta(): SuccessResponse = SuccessResponse.of(adminBadgeFinder.findMeta()) + + @RequireAdmin + @GetMapping("/{badgeId}") + fun findById( + @PathVariable badgeId: UUID, + ): SuccessResponse = SuccessResponse.of(adminBadgeFinder.findById(badgeId)) + @RequireAdmin @GetMapping("/{badgeId}/holders") fun findHolders( @PathVariable badgeId: UUID, ): SuccessResponse> = SuccessResponse.of(adminBadgeFinder.findHolders(badgeId)) + + @RequireAdmin + @PostMapping + fun create( + @RequestBody request: AdminBadgeCreateRequest, + @CurrentAdminId adminId: UUID, + ): SuccessResponse { + val result = adminBadgeRegister.create(request.toCommand()) + auditLogger.log( + actorId = adminId, + actorType = ActorType.ADMIN, + action = AuditAction.ADMIN_BADGE_CREATED, + targetId = result.id, + targetType = "BADGE", + ) + return SuccessResponse.of(result) + } + + @RequireAdmin + @PutMapping("/{badgeId}") + fun update( + @PathVariable badgeId: UUID, + @RequestBody request: AdminBadgeUpdateRequest, + @CurrentAdminId adminId: UUID, + ): SuccessResponse { + val result = adminBadgeRegister.update(badgeId, request.toCommand()) + auditLogger.log( + actorId = adminId, + actorType = ActorType.ADMIN, + action = AuditAction.ADMIN_BADGE_UPDATED, + targetId = badgeId, + targetType = "BADGE", + ) + return SuccessResponse.of(result) + } + + @RequireAdmin + @PatchMapping("/{badgeId}/active") + fun changeActive( + @PathVariable badgeId: UUID, + @RequestBody request: AdminBadgeActiveRequest, + @CurrentAdminId adminId: UUID, + ): SuccessResponse { + val result = adminBadgeRegister.changeActive(badgeId, request.active) + auditLogger.log( + actorId = adminId, + actorType = ActorType.ADMIN, + action = AuditAction.ADMIN_BADGE_ACTIVE_CHANGED, + targetId = badgeId, + targetType = "BADGE", + payload = mapOf("active" to request.active), + ) + return SuccessResponse.of(result) + } +} + +data class AdminBadgeCreateRequest( + val name: String, + val description: String, + val category: String, + val conditionType: String, + val threshold: Int, + val params: Map? = null, + val iconUrl: String? = null, + val congratsTitle: String? = null, + val congratsMessage: String? = null, +) { + fun toCommand() = + AdminBadgeCreateCommand( + name = name, + description = description, + category = category, + conditionType = conditionType, + threshold = threshold, + params = params, + iconUrl = iconUrl, + congratsTitle = congratsTitle, + congratsMessage = congratsMessage, + ) } + +data class AdminBadgeUpdateRequest( + val name: String, + val description: String, + val category: String, + val conditionType: String, + val threshold: Int, + val params: Map? = null, + val iconUrl: String? = null, + val congratsTitle: String? = null, + val congratsMessage: String? = null, +) { + fun toCommand() = + AdminBadgeUpdateCommand( + name = name, + description = description, + category = category, + conditionType = conditionType, + threshold = threshold, + params = params, + iconUrl = iconUrl, + congratsTitle = congratsTitle, + congratsMessage = congratsMessage, + ) +} + +data class AdminBadgeActiveRequest( + val active: Boolean, +) diff --git a/src/main/kotlin/com/didit/adapter/webapi/auth/UserApi.kt b/src/main/kotlin/com/didit/adapter/webapi/auth/UserApi.kt index cf93e0ba..3be4e236 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/auth/UserApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/auth/UserApi.kt @@ -11,6 +11,7 @@ import com.didit.adapter.webapi.auth.dto.UserProfileResponse import com.didit.adapter.webapi.auth.dto.UserProfileResponseV2 import com.didit.adapter.webapi.response.SuccessResponse import com.didit.application.achievement.provided.BadgeFinder +import com.didit.application.achievement.provided.UserLevelFinder import com.didit.application.audit.Audit import com.didit.application.audit.AuditAction import com.didit.application.auth.provided.UserFinder @@ -31,6 +32,7 @@ class UserApi( private val userFinder: UserFinder, private val userRegister: UserRegister, private val badgeFinder: BadgeFinder, + private val userLevelFinder: UserLevelFinder, ) { @GetMapping("/api/v1/users/nickname/check") fun checkNickname( @@ -96,8 +98,9 @@ class UserApi( ): SuccessResponse { val user = userFinder.findByIdOrThrow(userId) val recentBadges = badgeFinder.findRecent(userId) + val currentLevel = userLevelFinder.getCurrentLevel(userId) - return SuccessResponse.of(UserProfileResponseV2.from(user, recentBadges)) + return SuccessResponse.of(UserProfileResponseV2.from(user, currentLevel, recentBadges)) } @Deprecated("Use v2 endpoint", replaceWith = ReplaceWith("updateProfileV2")) diff --git a/src/main/kotlin/com/didit/adapter/webapi/auth/dto/UserProfileResponseV2.kt b/src/main/kotlin/com/didit/adapter/webapi/auth/dto/UserProfileResponseV2.kt index f4800054..1c76a405 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/auth/dto/UserProfileResponseV2.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/auth/dto/UserProfileResponseV2.kt @@ -14,11 +14,13 @@ data class UserProfileResponseV2( val age: UserAge?, val experience: UserExperience?, val provider: Provider, + val currentLevel: Int, val recentBadges: List = emptyList(), ) { companion object { fun from( user: User, + currentLevel: Int, recentBadges: List = emptyList(), ) = UserProfileResponseV2( nickname = user.nickname, @@ -27,6 +29,7 @@ data class UserProfileResponseV2( age = user.age, experience = user.experience, provider = user.provider, + currentLevel = currentLevel, recentBadges = recentBadges, ) } diff --git a/src/main/kotlin/com/didit/adapter/webapi/home/HomeApi.kt b/src/main/kotlin/com/didit/adapter/webapi/home/HomeApi.kt index bd2bbc78..154e81c9 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/home/HomeApi.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/home/HomeApi.kt @@ -6,7 +6,10 @@ import com.didit.adapter.webapi.home.dto.HomeResponse import com.didit.adapter.webapi.home.dto.HomeV2Response import com.didit.adapter.webapi.response.SuccessResponse import com.didit.adapter.webapi.retrospect.dto.RetrospectiveListItemV2Response +import com.didit.application.achievement.provided.MissionFinder +import com.didit.application.achievement.provided.UserMissionRegister import com.didit.application.auth.provided.UserFinder +import com.didit.application.notification.provided.NotificationHistoryFinder import com.didit.application.retrospect.provided.RetrospectiveFinder import com.didit.domain.shared.ServiceTime import org.springframework.web.bind.annotation.GetMapping @@ -17,6 +20,9 @@ import java.util.UUID class HomeApi( private val userFinder: UserFinder, private val retrospectiveFinder: RetrospectiveFinder, + private val notificationHistoryFinder: NotificationHistoryFinder, + private val missionFinder: MissionFinder, + private val userMissionRegister: UserMissionRegister, ) { @Deprecated("Use v2 endpoint", replaceWith = ReplaceWith("getHomeV2")) @RequireAuth @@ -45,6 +51,8 @@ class HomeApi( fun getHomeV2( @CurrentUserId userId: UUID, ): SuccessResponse { + userMissionRegister.ensureInitialized(userId) + val user = userFinder.findByIdOrThrow(userId) val recentRetros = retrospectiveFinder.findRecentWithProjectAndTagsByUserId(userId, limit = 5) val todayCount = retrospectiveFinder.countByUserIdAndDate(userId, ServiceTime.today()) @@ -53,6 +61,8 @@ class HomeApi( HomeV2Response( nickname = user.nickname ?: "", todayRetrospectiveCount = todayCount, + hasUnreadNotification = notificationHistoryFinder.hasUnread(userId), + mission = missionFinder.getCurrentMission(userId), recentRetrospectives = recentRetros.map { RetrospectiveListItemV2Response.from(it) diff --git a/src/main/kotlin/com/didit/adapter/webapi/home/dto/HomeV2Response.kt b/src/main/kotlin/com/didit/adapter/webapi/home/dto/HomeV2Response.kt index 058382b6..0af97a74 100644 --- a/src/main/kotlin/com/didit/adapter/webapi/home/dto/HomeV2Response.kt +++ b/src/main/kotlin/com/didit/adapter/webapi/home/dto/HomeV2Response.kt @@ -1,9 +1,12 @@ package com.didit.adapter.webapi.home.dto import com.didit.adapter.webapi.retrospect.dto.RetrospectiveListItemV2Response +import com.didit.application.achievement.dto.CurrentMissionResponse data class HomeV2Response( val nickname: String, val todayRetrospectiveCount: Int, + val hasUnreadNotification: Boolean, + val mission: CurrentMissionResponse, val recentRetrospectives: List, ) diff --git a/src/main/kotlin/com/didit/application/achievement/BadgeAwarderService.kt b/src/main/kotlin/com/didit/application/achievement/BadgeAwarderService.kt index f8921b87..bd610934 100644 --- a/src/main/kotlin/com/didit/application/achievement/BadgeAwarderService.kt +++ b/src/main/kotlin/com/didit/application/achievement/BadgeAwarderService.kt @@ -1,19 +1,25 @@ package com.didit.application.achievement import com.didit.application.achievement.provided.BadgeAwarder +import com.didit.application.achievement.provided.DailyAccessTracker import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.DailyAccessStreakRepository +import com.didit.application.achievement.required.OrganizationAchievementReader import com.didit.application.achievement.required.RetrospectAchievementReader -import com.didit.application.achievement.required.StreakRepository import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.achievement.required.WeeklyRetroStreakRepository import com.didit.application.audit.ActorType import com.didit.application.audit.AuditAction import com.didit.application.audit.AuditLogger import com.didit.domain.achievement.Badge -import com.didit.domain.achievement.Streak +import com.didit.domain.achievement.BadgeConditionType +import com.didit.domain.achievement.DailyAccessStreak import com.didit.domain.achievement.UserBadge +import com.didit.domain.achievement.WeeklyRetroStreak import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek import java.time.LocalDate import java.util.UUID @@ -22,8 +28,11 @@ import java.util.UUID class BadgeAwarderService( private val badgeRepository: BadgeRepository, private val userBadgeRepository: UserBadgeRepository, - private val streakRepository: StreakRepository, + private val weeklyRetroStreakRepository: WeeklyRetroStreakRepository, + private val dailyAccessStreakRepository: DailyAccessStreakRepository, + private val dailyAccessTracker: DailyAccessTracker, private val retrospectAchievementReader: RetrospectAchievementReader, + private val organizationAchievementReader: OrganizationAchievementReader, private val badgeConditionChecker: BadgeConditionChecker, private val auditLogger: AuditLogger, ) : BadgeAwarder { @@ -36,60 +45,94 @@ class BadgeAwarderService( userId: UUID, retroDate: LocalDate, ): List { - val streak = updateStreak(userId, retroDate) - val context = buildContext(userId, retroDate, streak) + val weeklyStreak = updateWeeklyRetroStreak(userId, retroDate) + val dailyStreak = dailyAccessStreakRepository.findByUserId(userId) ?: DailyAccessStreak.create(userId) + val context = buildContext(userId, retroDate, weeklyStreak, dailyStreak) + val acquiredBadgeIds = userBadgeRepository.findAllByUserId(userId).map { it.badgeId }.toSet() + + val satisfied = + badgeRepository + .findAll() + .filter { it.active } + .filter { it.id !in acquiredBadgeIds } + .filter { badgeConditionChecker.isSatisfied(it.condition, context) } + + return awardSatisfied(userId, satisfied) + } - val allBadges = badgeRepository.findAll() + @Transactional + override fun awardAccessBadges( + userId: UUID, + accessDateKst: LocalDate, + ): List { + val dailyStreak = dailyAccessTracker.recordAccess(userId, accessDateKst) val acquiredBadgeIds = userBadgeRepository.findAllByUserId(userId).map { it.badgeId }.toSet() - val newBadges = - allBadges - .filter { badge -> badge.id !in acquiredBadgeIds } - .filter { badge -> badgeConditionChecker.isSatisfied(badge.conditionType, context) } - .onEach { badge -> - userBadgeRepository.save(UserBadge.create(userId, badge.id)) + val satisfied = + badgeRepository + .findAll() + .filter { it.active } + .filter { it.condition.conditionType == BadgeConditionType.DAILY_ACCESS_STREAK } + .filter { it.id !in acquiredBadgeIds } + .filter { dailyStreak.isStreak(it.condition.threshold) } - auditLogger.log( - actorId = userId, - actorType = ActorType.SYSTEM, - action = AuditAction.BADGE_ACQUIRED, - targetId = badge.id, - targetType = "BADGE", - payload = - mapOf( - "badgeName" to badge.name, - "conditionType" to badge.conditionType.name, - ), - ) - } + return awardSatisfied(userId, satisfied) + } - if (newBadges.isNotEmpty()) { - logger.info("배지 획득 - userId: $userId, badges: ${newBadges.map { it.name }}") + private fun awardSatisfied( + userId: UUID, + satisfied: List, + ): List { + satisfied.forEach { badge -> + userBadgeRepository.save(UserBadge.create(userId, badge.id)) + + auditLogger.log( + actorId = userId, + actorType = ActorType.SYSTEM, + action = AuditAction.BADGE_ACQUIRED, + targetId = badge.id, + targetType = "BADGE", + payload = + mapOf( + "badgeName" to badge.name, + "conditionType" to badge.condition.conditionType.name, + "threshold" to badge.condition.threshold, + ), + ) } - return newBadges + if (satisfied.isNotEmpty()) { + logger.info("배지 획득 - userId: $userId, badges: ${satisfied.map { it.name }}") + } + + return satisfied } - private fun updateStreak( + private fun updateWeeklyRetroStreak( userId: UUID, retroDate: LocalDate, - ): Streak { - val streak = streakRepository.findByUserId(userId) ?: Streak.create(userId) - streak.update(retroDate) - return streakRepository.save(streak) + ): WeeklyRetroStreak { + val streak = weeklyRetroStreakRepository.findByUserId(userId) ?: WeeklyRetroStreak.create(userId) + streak.recordRetro(retroDate) + return weeklyRetroStreakRepository.save(streak) } private fun buildContext( userId: UUID, retroDate: LocalDate, - streak: Streak, + weeklyStreak: WeeklyRetroStreak, + dailyStreak: DailyAccessStreak, ): BadgeCheckContext = BadgeCheckContext( userId = userId, - totalRetroCount = retrospectAchievementReader.countCompletedRetros(userId), - streak = streak, - deepQuestionCount = retrospectAchievementReader.countDeepQuestionAnswers(userId), retroDate = retroDate, - weeklyGoalAchievedWeeks = retrospectAchievementReader.countWeeklyGoalAchievedWeeks(userId), + totalRetroCount = retrospectAchievementReader.countCompletedRetros(userId), + currentWeekRetroCount = retrospectAchievementReader.countRetrosInWeek(userId, retroDate.with(DayOfWeek.MONDAY)), + weeklyRetroStreak = weeklyStreak, + weeklyStreakWithMin3 = retrospectAchievementReader.countConsecutiveWeeksWithMinRetros(userId, 3), + dailyAccessStreak = dailyStreak, + projectCount = organizationAchievementReader.countProjects(userId), + projectAssignedRetroCount = organizationAchievementReader.countProjectAssignedRetros(userId), + maxRetroInOneProject = organizationAchievementReader.maxRetroCountInOneProject(userId), ) } diff --git a/src/main/kotlin/com/didit/application/achievement/BadgeCheckContext.kt b/src/main/kotlin/com/didit/application/achievement/BadgeCheckContext.kt index 372db620..0e8e0104 100644 --- a/src/main/kotlin/com/didit/application/achievement/BadgeCheckContext.kt +++ b/src/main/kotlin/com/didit/application/achievement/BadgeCheckContext.kt @@ -1,14 +1,19 @@ package com.didit.application.achievement -import com.didit.domain.achievement.Streak +import com.didit.domain.achievement.DailyAccessStreak +import com.didit.domain.achievement.WeeklyRetroStreak import java.time.LocalDate import java.util.UUID data class BadgeCheckContext( val userId: UUID, - val totalRetroCount: Int, - val streak: Streak, - val deepQuestionCount: Int, val retroDate: LocalDate, - val weeklyGoalAchievedWeeks: Int, + val totalRetroCount: Int, + val currentWeekRetroCount: Int, + val weeklyRetroStreak: WeeklyRetroStreak, + val weeklyStreakWithMin3: Int, + val dailyAccessStreak: DailyAccessStreak, + val projectCount: Int, + val projectAssignedRetroCount: Int, + val maxRetroInOneProject: Int, ) diff --git a/src/main/kotlin/com/didit/application/achievement/BadgeConditionChecker.kt b/src/main/kotlin/com/didit/application/achievement/BadgeConditionChecker.kt index 19636163..073726a3 100644 --- a/src/main/kotlin/com/didit/application/achievement/BadgeConditionChecker.kt +++ b/src/main/kotlin/com/didit/application/achievement/BadgeConditionChecker.kt @@ -1,37 +1,35 @@ package com.didit.application.achievement +import com.didit.domain.achievement.BadgeCondition import com.didit.domain.achievement.BadgeConditionType import org.springframework.stereotype.Component @Component class BadgeConditionChecker { fun isSatisfied( - conditionType: BadgeConditionType, + condition: BadgeCondition, context: BadgeCheckContext, ): Boolean = - when (conditionType) { - BadgeConditionType.FIRST_RETRO -> checkFirstRetro(context) - BadgeConditionType.STREAK_3_DAYS -> checkStreak3Days(context) - BadgeConditionType.TOTAL_30 -> checkTotal30(context) - BadgeConditionType.DEEP_QUESTION_1 -> checkDeepQuestion(context, 1) - BadgeConditionType.DEEP_QUESTION_5 -> checkDeepQuestion(context, 5) - BadgeConditionType.DEEP_QUESTION_10 -> checkDeepQuestion(context, 10) - BadgeConditionType.WEEKLY_3_FIRST -> checkWeekly3First(context) - BadgeConditionType.WEEKLY_3_THREE_WEEKS -> checkWeekly3ThreeWeeks(context) + when (condition.conditionType) { + BadgeConditionType.CUMULATIVE_RETRO -> context.totalRetroCount >= condition.threshold + BadgeConditionType.WEEKLY_RETRO_COUNT -> context.currentWeekRetroCount >= condition.threshold + BadgeConditionType.WEEKLY_STREAK -> checkWeeklyStreak(condition, context) + BadgeConditionType.DAILY_ACCESS_STREAK -> context.dailyAccessStreak.currentStreak >= condition.threshold + BadgeConditionType.PROJECT_COUNT -> context.projectCount >= condition.threshold + BadgeConditionType.PROJECT_TAGGED_RETRO -> context.projectAssignedRetroCount >= condition.threshold + BadgeConditionType.PROJECT_RETRO_IN_ONE -> context.maxRetroInOneProject >= condition.threshold } - private fun checkFirstRetro(context: BadgeCheckContext): Boolean = context.totalRetroCount == 1 - - private fun checkStreak3Days(context: BadgeCheckContext): Boolean = context.streak.isStreak(3) - - private fun checkTotal30(context: BadgeCheckContext): Boolean = context.totalRetroCount >= 30 - - private fun checkDeepQuestion( + private fun checkWeeklyStreak( + condition: BadgeCondition, context: BadgeCheckContext, - count: Int, - ): Boolean = context.deepQuestionCount >= count - - private fun checkWeekly3First(context: BadgeCheckContext): Boolean = context.weeklyGoalAchievedWeeks >= 1 - - private fun checkWeekly3ThreeWeeks(context: BadgeCheckContext): Boolean = context.weeklyGoalAchievedWeeks >= 3 + ): Boolean { + val consecutive = + when (condition.weeklyMinCount()) { + 1 -> context.weeklyRetroStreak.currentWeeks + 3 -> context.weeklyStreakWithMin3 + else -> return false + } + return consecutive >= condition.threshold + } } diff --git a/src/main/kotlin/com/didit/application/achievement/BadgeEventListener.kt b/src/main/kotlin/com/didit/application/achievement/BadgeEventListener.kt index 23930fb7..0a1a86b5 100644 --- a/src/main/kotlin/com/didit/application/achievement/BadgeEventListener.kt +++ b/src/main/kotlin/com/didit/application/achievement/BadgeEventListener.kt @@ -1,6 +1,7 @@ package com.didit.application.achievement import com.didit.application.achievement.provided.BadgeAwarder +import com.didit.domain.auth.UserLoggedInEvent import com.didit.domain.retrospect.RetrospectiveCompletedEvent import org.slf4j.LoggerFactory import org.springframework.stereotype.Component @@ -26,4 +27,14 @@ class BadgeEventListener( logger.error("배지 부여 실패 - userId: ${event.userId}", e) } } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onUserLoggedIn(event: UserLoggedInEvent) { + try { + badgeAwarder.awardAccessBadges(event.userId, event.accessDateKst) + } catch (e: Exception) { + logger.error("로그인 트리거 배지 부여 실패 - userId: ${event.userId}", e) + } + } } diff --git a/src/main/kotlin/com/didit/application/achievement/DailyAccessTrackingService.kt b/src/main/kotlin/com/didit/application/achievement/DailyAccessTrackingService.kt new file mode 100644 index 00000000..11b69300 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/DailyAccessTrackingService.kt @@ -0,0 +1,32 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.provided.DailyAccessTracker +import com.didit.application.achievement.required.DailyAccessStreakRepository +import com.didit.domain.achievement.DailyAccessStreak +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.util.UUID + +@Transactional(readOnly = true) +@Service +class DailyAccessTrackingService( + private val dailyAccessStreakRepository: DailyAccessStreakRepository, +) : DailyAccessTracker { + companion object { + private val logger = LoggerFactory.getLogger(DailyAccessTrackingService::class.java) + } + + @Transactional + override fun recordAccess( + userId: UUID, + accessDateKst: LocalDate, + ): DailyAccessStreak { + val streak = dailyAccessStreakRepository.findByUserId(userId) ?: DailyAccessStreak.create(userId) + streak.recordAccess(accessDateKst) + val saved = dailyAccessStreakRepository.save(streak) + logger.info("일일 접속 스트릭 갱신 - userId: $userId, currentStreak: ${saved.currentStreak}") + return saved + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/MissionConditionChecker.kt b/src/main/kotlin/com/didit/application/achievement/MissionConditionChecker.kt new file mode 100644 index 00000000..fe9b6055 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/MissionConditionChecker.kt @@ -0,0 +1,126 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.domain.achievement.Mission +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.MissionType +import com.didit.domain.achievement.UserMission +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.time.LocalDate +import java.util.UUID + +@Component +class MissionConditionChecker( + private val userMissionRepository: UserMissionRepository, +) { + companion object { + private val logger = LoggerFactory.getLogger(MissionConditionChecker::class.java) + } + + fun checkAndUpdate( + userMission: UserMission, + userId: UUID, + retroDate: LocalDate, + mission: Mission, + ): Boolean = + when (mission.missionType) { + MissionType.FIRST_RETRO -> checkFirstRetro(userMission, mission) + MissionType.TIME_LIMITED -> checkTimeLimited(userMission, userId, mission) + MissionType.CONSECUTIVE_WEEK -> checkConsecutiveWeek(userMission, retroDate, mission) + MissionType.CUMULATIVE_RETRO -> checkCumulativeRetro(userMission, userId, mission) + } + + private fun checkFirstRetro( + userMission: UserMission, + mission: Mission, + ): Boolean { + userMission.incrementProgress() + return true + } + + private fun checkTimeLimited( + userMission: UserMission, + userId: UUID, + mission: Mission, + ): Boolean { + checkAndHandleLv2Expiry(userMission, mission) + + if (userMission.status != MissionStatus.IN_PROGRESS) { + return false + } + + val retroCount = + userMissionRepository.countRetrosAfter( + userId = userId, + startedAt = userMission.startedAt, + ) + + userMission.progress = retroCount + + return retroCount >= mission.targetCount + } + + private fun checkAndHandleLv2Expiry( + userMission: UserMission, + mission: Mission, + ) { + val durationDays = mission.durationDays ?: 7 + val expiryDate = userMission.startedAt.toLocalDate().plusDays(durationDays.toLong()) + + if (LocalDate.now() > expiryDate && userMission.progress < mission.targetCount) { + userMission.setFailureWaitingConfirm() + logger.info( + "Lv.2 미션 만료로 실패 - userId: ${userMission.userId}, " + + "progress: ${userMission.progress}, expired: $expiryDate", + ) + } + } + + private fun checkConsecutiveWeek( + userMission: UserMission, + retroDate: LocalDate, + mission: Mission, + ): Boolean { + val thisWeekStart = getWeekStartDate(retroDate) + val lastRetroDate = userMission.lastRetroDate + + if (lastRetroDate == null) { + userMission.progress = 1 + } else { + val lastWeekStart = getWeekStartDate(lastRetroDate) + when { + thisWeekStart == lastWeekStart -> Unit + thisWeekStart == lastWeekStart.plusWeeks(1) -> userMission.incrementProgress() + else -> userMission.progress = 1 + } + } + + if (lastRetroDate == null || retroDate.isAfter(lastRetroDate)) { + userMission.lastRetroDate = retroDate + } + + return userMission.progress >= mission.targetCount + } + + private fun getWeekStartDate(date: LocalDate): LocalDate { + val dayOfWeek = date.dayOfWeek.value + return date.minusDays((dayOfWeek - 1).toLong()) + } + + private fun checkCumulativeRetro( + userMission: UserMission, + userId: UUID, + mission: Mission, + ): Boolean { + val retroCount = + userMissionRepository.countRetrosAfter( + userId = userId, + startedAt = userMission.startedAt, + ) + + userMission.progress = retroCount + + return retroCount >= mission.targetCount + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/MissionEventListener.kt b/src/main/kotlin/com/didit/application/achievement/MissionEventListener.kt new file mode 100644 index 00000000..bc9db212 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/MissionEventListener.kt @@ -0,0 +1,33 @@ +package com.didit.application.achievement + +import com.didit.domain.retrospect.RetrospectiveCompletedEvent +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class MissionEventListener( + private val missionProgressService: MissionProgressService, +) { + companion object { + private val logger = LoggerFactory.getLogger(MissionEventListener::class.java) + } + + @EventListener + @Transactional + fun onRetrospectiveCompleted(event: RetrospectiveCompletedEvent) { + try { + missionProgressService.updateProgressOnRetroComplete( + userId = event.userId, + retrospectiveId = event.retrospectiveId, + retroDate = event.retroDate, + ) + } catch (e: Exception) { + logger.error( + "미션 진행도 업데이트 실패 - userId: ${event.userId}, retrospectiveId: ${event.retrospectiveId}", + e, + ) + } + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/MissionProgressService.kt b/src/main/kotlin/com/didit/application/achievement/MissionProgressService.kt new file mode 100644 index 00000000..a5de5988 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/MissionProgressService.kt @@ -0,0 +1,130 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.UserLevel +import com.didit.domain.achievement.UserMission +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.util.UUID + +@Service +@Transactional +class MissionProgressService( + private val userMissionRepository: UserMissionRepository, + private val userLevelRepository: UserLevelRepository, + private val missionRepository: MissionRepository, + private val missionConditionChecker: MissionConditionChecker, +) { + companion object { + private val logger = LoggerFactory.getLogger(MissionProgressService::class.java) + private const val LEVEL_UP_POPUP_MIN_LEVEL = 2 + } + + fun updateProgressOnRetroComplete( + userId: UUID, + retrospectiveId: UUID, + retroDate: LocalDate, + ) { + val userLevel = + userLevelRepository.findByUserId(userId) ?: run { + userLevelRepository.save(UserLevel(userId)) + } + + var currentUserMission = userMissionRepository.findCurrentMissionByUserId(userId) + + if (currentUserMission == null) { + val lv1Mission = + missionRepository.findByLevel(1) ?: run { + logger.warn("Lv.1 미션 정의가 없습니다") + return + } + val userMission = UserMission.create(userId, lv1Mission.id) + userMission.levelUpPopupShown = true + userMissionRepository.save(userMission) + updateProgressOnRetroComplete(userId, retrospectiveId, retroDate) + return + } + + if (currentUserMission.status != MissionStatus.IN_PROGRESS) { + logger.debug("미확인 실패 미션 - 진행률 갱신 보류, userId: $userId, status: ${currentUserMission.status}") + return + } + + val mission = + missionRepository.findAll().find { it.id == currentUserMission.missionId } ?: run { + logger.warn("미션 정보를 찾을 수 없습니다 - missionId: ${currentUserMission.missionId}") + return + } + + val isCompleted = + missionConditionChecker.checkAndUpdate( + userMission = currentUserMission, + userId = userId, + retroDate = retroDate, + mission = mission, + ) + + if (isCompleted) { + completeMission(currentUserMission, userLevel) + logger.info( + "미션 완료 - userId: $userId, level: ${userLevel.currentLevel}, " + + "missionType: ${mission.missionType}", + ) + } else { + userMissionRepository.save(currentUserMission) + logger.debug( + "미션 진행 중 - userId: $userId, level: ${userLevel.currentLevel}, " + + "progress: ${currentUserMission.progress}/${mission.targetCount}", + ) + } + } + + private fun completeMission( + userMission: UserMission, + userLevel: UserLevel, + ) { + userMission.complete() + userMissionRepository.save(userMission) + awardNextLevel(userLevel, userMission.userId) + } + + private fun awardNextLevel( + userLevel: UserLevel, + userId: UUID, + ) { + val currentLevel = userLevel.currentLevel + + if (currentLevel >= 10) { + logger.info("사용자가 최대 레벨(10)에 도달했습니다 - userId: $userId") + return + } + + userLevel.levelUp() + userLevelRepository.save(userLevel) + + val nextMissionLevel = userLevel.currentLevel + 1 + if (nextMissionLevel > 10) { + logger.info("모든 미션 완료 - userId: $userId, currentLevel: ${userLevel.currentLevel}") + return + } + + val nextMission = missionRepository.findByLevel(nextMissionLevel) + if (nextMission != null) { + val nextUserMission = UserMission.create(userId, nextMission.id) + nextUserMission.levelUpPopupShown = userLevel.currentLevel < LEVEL_UP_POPUP_MIN_LEVEL + userMissionRepository.save(nextUserMission) + + logger.info( + "레벨업 및 다음 미션 생성 - userId: $userId, " + + "currentLevel: ${userLevel.currentLevel}, missionType: ${nextMission.missionType}", + ) + } else { + logger.warn("Lv.$nextMissionLevel 미션 정의가 없습니다") + } + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/MissionQueryService.kt b/src/main/kotlin/com/didit/application/achievement/MissionQueryService.kt new file mode 100644 index 00000000..8556caf4 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/MissionQueryService.kt @@ -0,0 +1,129 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.dto.CurrentMissionResponse +import com.didit.application.achievement.dto.MissionInfo +import com.didit.application.achievement.dto.PopupStatus +import com.didit.application.achievement.dto.WeeklyStatus +import com.didit.application.achievement.exception.CurrentMissionNotFoundException +import com.didit.application.achievement.exception.MissionDefinitionNotFoundException +import com.didit.application.achievement.exception.UserLevelNotFoundException +import com.didit.application.achievement.provided.MissionFinder +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.achievement.Mission +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.MissionType +import com.didit.domain.achievement.UserMission +import com.didit.domain.shared.ServiceTime +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters +import java.util.UUID + +@Service +@Transactional(readOnly = true) +class MissionQueryService( + private val userLevelRepository: UserLevelRepository, + private val userMissionRepository: UserMissionRepository, + private val missionRepository: MissionRepository, + private val retrospectiveRepository: RetrospectiveRepository, +) : MissionFinder { + override fun getCurrentMission(userId: UUID): CurrentMissionResponse { + val userLevel = userLevelRepository.findByUserId(userId) ?: throw UserLevelNotFoundException(userId) + val userMission = + userMissionRepository.findCurrentMissionByUserId(userId) ?: throw CurrentMissionNotFoundException(userId) + val missionLevel = userLevel.currentLevel + 1 + val mission = + missionRepository.findByLevel(missionLevel) ?: throw MissionDefinitionNotFoundException(missionLevel) + + val missionInfo = + MissionInfo( + type = mission.missionType.name, + title = mission.title, + description = mission.description, + progress = userMission.progress, + target = mission.targetCount, + remainingDays = calculateDaysRemaining(mission, userMission), + cta = getMissionCta(mission), + ) + + val weeklyStatus = buildWeeklyStatus(mission, userMission, userId) + val popupStatus = getPopupStatus(userMission) + + return CurrentMissionResponse( + currentLevel = userLevel.currentLevel, + mission = missionInfo, + weeklyStatus = weeklyStatus, + popup = popupStatus, + ) + } + + private fun calculateDaysRemaining( + mission: Mission, + userMission: UserMission, + ): Int? { + if (mission.missionType != MissionType.TIME_LIMITED) { + return null + } + + val durationDays = mission.durationDays ?: return null + val startDate = userMission.startedAt.toLocalDate() + val endDate = startDate.plusDays(durationDays.toLong()) + val today = LocalDate.now() + + return (endDate.toEpochDay() - today.toEpochDay()).toInt() + } + + private fun buildWeeklyStatus( + mission: Mission, + userMission: UserMission, + userId: UUID, + ): WeeklyStatus? { + if (mission.missionType != MissionType.CONSECUTIVE_WEEK) { + return null + } + + val weekStartDate = getWeekStartDate(ServiceTime.today()) + val from = ServiceTime.startOfDayUtc(weekStartDate) + val to = ServiceTime.startOfDayUtc(weekStartDate.plusWeeks(1)) + + val completedRetros = retrospectiveRepository.findCompletedByUserIdAndPeriod(userId, from, to) + val completedDates = completedRetros.mapNotNull { it.completedAt?.let(ServiceTime::toServiceDate) }.toSet() + + val today = ServiceTime.today() + val days = mutableListOf() + + for (i in 0..6) { + val dayDate = weekStartDate.plusDays(i.toLong()) + val isCompleted = + if (dayDate > today) { + false + } else { + completedDates.contains(dayDate) + } + days.add(isCompleted) + } + + return WeeklyStatus( + show = true, + weekStart = weekStartDate, + days = days, + ) + } + + private fun getPopupStatus(userMission: UserMission): PopupStatus = + when { + !userMission.levelUpPopupShown -> PopupStatus(exists = true, type = "LEVEL_UP") + userMission.status == MissionStatus.WAIT_CONFIRM && !userMission.failurePopupShown -> + PopupStatus(exists = true, type = "FAILURE") + else -> PopupStatus(exists = false, type = null) + } + + private fun getMissionCta(mission: Mission): String = "회고 남기기" + + private fun getWeekStartDate(date: LocalDate): LocalDate = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) +} diff --git a/src/main/kotlin/com/didit/application/achievement/UserLevelQueryService.kt b/src/main/kotlin/com/didit/application/achievement/UserLevelQueryService.kt new file mode 100644 index 00000000..cfb25725 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/UserLevelQueryService.kt @@ -0,0 +1,19 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.provided.UserLevelFinder +import com.didit.application.achievement.required.UserLevelRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Transactional(readOnly = true) +@Service +class UserLevelQueryService( + private val userLevelRepository: UserLevelRepository, +) : UserLevelFinder { + override fun getCurrentLevel(userId: UUID): Int = userLevelRepository.findByUserId(userId)?.currentLevel ?: DEFAULT_LEVEL + + companion object { + private const val DEFAULT_LEVEL = 0 + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/UserMissionService.kt b/src/main/kotlin/com/didit/application/achievement/UserMissionService.kt new file mode 100644 index 00000000..a5b433f8 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/UserMissionService.kt @@ -0,0 +1,62 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.exception.CurrentMissionNotFoundException +import com.didit.application.achievement.provided.UserMissionRegister +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.domain.achievement.MissionType +import com.didit.domain.achievement.UserLevel +import com.didit.domain.achievement.UserMission +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID + +@Service +@Transactional +class UserMissionService( + private val userMissionRepository: UserMissionRepository, + private val missionRepository: MissionRepository, + private val userLevelRepository: UserLevelRepository, +) : UserMissionRegister { + override fun ensureInitialized(userId: UUID) { + if (userLevelRepository.existsByUserId(userId)) { + return + } + userLevelRepository.save(UserLevel.create(userId)) + + val lv1Mission = missionRepository.findByLevel(1) ?: return + val userMission = UserMission.create(userId, lv1Mission.id) + userMission.levelUpPopupShown = true + userMissionRepository.save(userMission) + } + + override fun confirmLevelUp(userId: UUID) { + val userMission = + userMissionRepository.findCurrentMissionByUserId(userId) + ?: throw CurrentMissionNotFoundException(userId) + + userMission.levelUpPopupShown = true + userMissionRepository.save(userMission) + } + + override fun retryMission(userId: UUID) { + val userMission = + userMissionRepository.findCurrentMissionByUserId(userId) + ?: throw CurrentMissionNotFoundException(userId) + + val mission = + missionRepository.findAll().find { it.id == userMission.missionId } + ?: throw CurrentMissionNotFoundException(userId) + + userMission.retry() + + if (mission.missionType == MissionType.TIME_LIMITED) { + userMission.startedAt = LocalDateTime.now() + } + + userMission.failurePopupShown = true + userMissionRepository.save(userMission) + } +} diff --git a/src/main/kotlin/com/didit/application/achievement/dto/BadgeResponse.kt b/src/main/kotlin/com/didit/application/achievement/dto/BadgeResponse.kt index 880271aa..53dde375 100644 --- a/src/main/kotlin/com/didit/application/achievement/dto/BadgeResponse.kt +++ b/src/main/kotlin/com/didit/application/achievement/dto/BadgeResponse.kt @@ -9,7 +9,12 @@ data class BadgeResponse( val id: UUID, val name: String, val description: String, + val category: String, val conditionType: String, + val threshold: Int, + val iconUrl: String?, + val congratsTitle: String?, + val congratsMessage: String?, val acquired: Boolean, val acquiredAt: LocalDateTime?, ) { @@ -22,7 +27,12 @@ data class BadgeResponse( id = badge.id, name = badge.name, description = badge.description, - conditionType = badge.conditionType.name, + category = badge.category.name, + conditionType = badge.condition.conditionType.name, + threshold = badge.condition.threshold, + iconUrl = badge.iconUrl, + congratsTitle = badge.congratsTitle, + congratsMessage = badge.congratsMessage, acquired = userBadge != null, acquiredAt = userBadge?.acquiredAt, ) diff --git a/src/main/kotlin/com/didit/application/achievement/dto/CurrentMissionResponse.kt b/src/main/kotlin/com/didit/application/achievement/dto/CurrentMissionResponse.kt new file mode 100644 index 00000000..d2e2b0d6 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/dto/CurrentMissionResponse.kt @@ -0,0 +1,8 @@ +package com.didit.application.achievement.dto + +data class CurrentMissionResponse( + val currentLevel: Int, + val mission: MissionInfo, + val weeklyStatus: WeeklyStatus?, + val popup: PopupStatus, +) diff --git a/src/main/kotlin/com/didit/application/achievement/dto/MissionInfo.kt b/src/main/kotlin/com/didit/application/achievement/dto/MissionInfo.kt new file mode 100644 index 00000000..fd13989b --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/dto/MissionInfo.kt @@ -0,0 +1,11 @@ +package com.didit.application.achievement.dto + +data class MissionInfo( + val type: String, + val title: String, + val description: String, + val progress: Int, + val target: Int, + val remainingDays: Int?, + val cta: String, +) diff --git a/src/main/kotlin/com/didit/application/achievement/dto/PopupStatus.kt b/src/main/kotlin/com/didit/application/achievement/dto/PopupStatus.kt new file mode 100644 index 00000000..04421876 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/dto/PopupStatus.kt @@ -0,0 +1,6 @@ +package com.didit.application.achievement.dto + +data class PopupStatus( + val exists: Boolean, + val type: String?, +) diff --git a/src/main/kotlin/com/didit/application/achievement/dto/WeeklyStatus.kt b/src/main/kotlin/com/didit/application/achievement/dto/WeeklyStatus.kt new file mode 100644 index 00000000..34216b1e --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/dto/WeeklyStatus.kt @@ -0,0 +1,9 @@ +package com.didit.application.achievement.dto + +import java.time.LocalDate + +data class WeeklyStatus( + val show: Boolean, + val weekStart: LocalDate, + val days: List, +) diff --git a/src/main/kotlin/com/didit/application/achievement/exception/MissionErrorCode.kt b/src/main/kotlin/com/didit/application/achievement/exception/MissionErrorCode.kt new file mode 100644 index 00000000..e30b9fd4 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/exception/MissionErrorCode.kt @@ -0,0 +1,15 @@ +package com.didit.application.achievement.exception + +import com.didit.application.common.exception.BaseErrorCode +import org.springframework.http.HttpStatus + +enum class MissionErrorCode( + override val status: HttpStatus, + override val detail: String, +) : BaseErrorCode { + USER_LEVEL_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "사용자의 레벨 정보를 찾을 수 없습니다."), + CURRENT_MISSION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "사용자의 현재 미션을 찾을 수 없습니다."), + MISSION_DEFINITION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "해당 미션 정의가 없습니다."), + USER_MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "미션을 찾을 수 없습니다."), + INVALID_POPUP_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 팝업 타입입니다."), +} diff --git a/src/main/kotlin/com/didit/application/achievement/exception/MissionException.kt b/src/main/kotlin/com/didit/application/achievement/exception/MissionException.kt new file mode 100644 index 00000000..4b151ae3 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/exception/MissionException.kt @@ -0,0 +1,39 @@ +package com.didit.application.achievement.exception + +import com.didit.application.common.exception.BusinessException +import java.util.UUID + +class UserLevelNotFoundException( + userId: UUID, +) : BusinessException( + MissionErrorCode.USER_LEVEL_NOT_FOUND, + "userId: $userId", + ) + +class CurrentMissionNotFoundException( + userId: UUID, +) : BusinessException( + MissionErrorCode.CURRENT_MISSION_NOT_FOUND, + "userId: $userId", + ) + +class MissionDefinitionNotFoundException( + level: Int, +) : BusinessException( + MissionErrorCode.MISSION_DEFINITION_NOT_FOUND, + "level: $level", + ) + +class UserMissionNotFoundException( + missionId: UUID, +) : BusinessException( + MissionErrorCode.USER_MISSION_NOT_FOUND, + "missionId: $missionId", + ) + +class InvalidPopupTypeException( + type: String, +) : BusinessException( + MissionErrorCode.INVALID_POPUP_TYPE, + "type: $type", + ) diff --git a/src/main/kotlin/com/didit/application/achievement/provided/BadgeAwarder.kt b/src/main/kotlin/com/didit/application/achievement/provided/BadgeAwarder.kt index b3226809..c1f65e16 100644 --- a/src/main/kotlin/com/didit/application/achievement/provided/BadgeAwarder.kt +++ b/src/main/kotlin/com/didit/application/achievement/provided/BadgeAwarder.kt @@ -9,4 +9,9 @@ interface BadgeAwarder { userId: UUID, retroDate: LocalDate, ): List + + fun awardAccessBadges( + userId: UUID, + accessDateKst: LocalDate, + ): List } diff --git a/src/main/kotlin/com/didit/application/achievement/provided/DailyAccessTracker.kt b/src/main/kotlin/com/didit/application/achievement/provided/DailyAccessTracker.kt new file mode 100644 index 00000000..65fb7263 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/provided/DailyAccessTracker.kt @@ -0,0 +1,12 @@ +package com.didit.application.achievement.provided + +import com.didit.domain.achievement.DailyAccessStreak +import java.time.LocalDate +import java.util.UUID + +interface DailyAccessTracker { + fun recordAccess( + userId: UUID, + accessDateKst: LocalDate, + ): DailyAccessStreak +} diff --git a/src/main/kotlin/com/didit/application/achievement/provided/MissionFinder.kt b/src/main/kotlin/com/didit/application/achievement/provided/MissionFinder.kt new file mode 100644 index 00000000..a9e8027c --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/provided/MissionFinder.kt @@ -0,0 +1,8 @@ +package com.didit.application.achievement.provided + +import com.didit.application.achievement.dto.CurrentMissionResponse +import java.util.UUID + +interface MissionFinder { + fun getCurrentMission(userId: UUID): CurrentMissionResponse +} diff --git a/src/main/kotlin/com/didit/application/achievement/provided/UserLevelFinder.kt b/src/main/kotlin/com/didit/application/achievement/provided/UserLevelFinder.kt new file mode 100644 index 00000000..a7382267 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/provided/UserLevelFinder.kt @@ -0,0 +1,7 @@ +package com.didit.application.achievement.provided + +import java.util.UUID + +interface UserLevelFinder { + fun getCurrentLevel(userId: UUID): Int +} diff --git a/src/main/kotlin/com/didit/application/achievement/provided/UserMissionRegister.kt b/src/main/kotlin/com/didit/application/achievement/provided/UserMissionRegister.kt new file mode 100644 index 00000000..92a15a90 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/provided/UserMissionRegister.kt @@ -0,0 +1,11 @@ +package com.didit.application.achievement.provided + +import java.util.UUID + +interface UserMissionRegister { + fun ensureInitialized(userId: UUID) + + fun confirmLevelUp(userId: UUID) + + fun retryMission(userId: UUID) +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/BadgeRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/BadgeRepository.kt index 9892662a..53d52215 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/BadgeRepository.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/BadgeRepository.kt @@ -9,5 +9,7 @@ interface BadgeRepository : Repository { @Query("SELECT b FROM Badge b ORDER BY b.createdAt ASC") fun findAll(): List + fun findById(id: UUID): Badge? + fun save(badge: Badge): Badge } diff --git a/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt new file mode 100644 index 00000000..031c3886 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepository.kt @@ -0,0 +1,11 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.DailyAccessStreak +import org.springframework.data.repository.Repository +import java.util.UUID + +interface DailyAccessStreakRepository : Repository { + fun findByUserId(userId: UUID): DailyAccessStreak? + + fun save(streak: DailyAccessStreak): DailyAccessStreak +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/MissionRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/MissionRepository.kt new file mode 100644 index 00000000..d2fbaaf7 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/MissionRepository.kt @@ -0,0 +1,18 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.Mission +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.Repository +import java.util.UUID + +interface MissionRepository : Repository { + fun findByLevel(level: Int): Mission? + + @Query("SELECT m FROM Mission m WHERE m.isActive = true ORDER BY m.level ASC") + fun findAllActive(): List + + @Query("SELECT m FROM Mission m ORDER BY m.level ASC") + fun findAll(): List + + fun save(mission: Mission): Mission +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/OrganizationAchievementReader.kt b/src/main/kotlin/com/didit/application/achievement/required/OrganizationAchievementReader.kt new file mode 100644 index 00000000..97d4884c --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/OrganizationAchievementReader.kt @@ -0,0 +1,11 @@ +package com.didit.application.achievement.required + +import java.util.UUID + +interface OrganizationAchievementReader { + fun countProjects(userId: UUID): Int + + fun countProjectAssignedRetros(userId: UUID): Int + + fun maxRetroCountInOneProject(userId: UUID): Int +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/RetrospectAchievementReader.kt b/src/main/kotlin/com/didit/application/achievement/required/RetrospectAchievementReader.kt index e91e13f7..9ca18a36 100644 --- a/src/main/kotlin/com/didit/application/achievement/required/RetrospectAchievementReader.kt +++ b/src/main/kotlin/com/didit/application/achievement/required/RetrospectAchievementReader.kt @@ -1,11 +1,18 @@ package com.didit.application.achievement.required +import java.time.LocalDate import java.util.UUID interface RetrospectAchievementReader { fun countCompletedRetros(userId: UUID): Int - fun countDeepQuestionAnswers(userId: UUID): Int + fun countRetrosInWeek( + userId: UUID, + weekStartKst: LocalDate, + ): Int - fun countWeeklyGoalAchievedWeeks(userId: UUID): Int + fun countConsecutiveWeeksWithMinRetros( + userId: UUID, + weeklyMinCount: Int, + ): Int } diff --git a/src/main/kotlin/com/didit/application/achievement/required/StreakRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/StreakRepository.kt deleted file mode 100644 index 4ffc1350..00000000 --- a/src/main/kotlin/com/didit/application/achievement/required/StreakRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.didit.application.achievement.required - -import com.didit.domain.achievement.Streak -import org.springframework.data.repository.Repository -import java.util.UUID - -interface StreakRepository : Repository { - fun findByUserId(userId: UUID): Streak? - - fun save(streak: Streak): Streak -} diff --git a/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt new file mode 100644 index 00000000..669dc4dc --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/UserLevelRepository.kt @@ -0,0 +1,26 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.UserLevel +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.Repository +import java.util.UUID + +interface UserLevelRepository : Repository { + fun findByUserId(userId: UUID): UserLevel? + + fun save(userLevel: UserLevel): UserLevel + + fun existsByUserId(userId: UUID): Boolean + + @Query( + "SELECT ul.currentLevel AS level, COUNT(ul) AS count FROM UserLevel ul " + + "GROUP BY ul.currentLevel ORDER BY ul.currentLevel", + ) + fun countGroupByLevel(): List +} + +interface LevelCount { + fun getLevel(): Int + + fun getCount(): Long +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt new file mode 100644 index 00000000..03816b1c --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/UserMissionRepository.kt @@ -0,0 +1,115 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.MissionType +import com.didit.domain.achievement.UserMission +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.Repository +import org.springframework.data.repository.query.Param +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +interface UserMissionRepository : Repository { + fun findByIdAndUserId( + id: UUID, + userId: UUID, + ): UserMission? + + @Query( + "SELECT um FROM UserMission um " + + "WHERE um.userId = :userId AND um.status = :status " + + "ORDER BY um.createdAt DESC LIMIT 1", + ) + fun findByUserIdAndStatus( + userId: UUID, + status: MissionStatus, + ): UserMission? + + @Query( + "SELECT um FROM UserMission um " + + "WHERE um.userId = :userId AND um.status IN ('IN_PROGRESS', 'WAIT_CONFIRM') " + + "ORDER BY um.createdAt DESC LIMIT 1", + ) + fun findCurrentMissionByUserId(userId: UUID): UserMission? + + fun save(userMission: UserMission): UserMission + + @Query("SELECT um FROM UserMission um WHERE um.userId = :userId ORDER BY um.createdAt DESC") + fun findByUserId(userId: UUID): List + + @Query( + """ + SELECT COUNT(r) FROM Retrospective r + WHERE r.userId = :userId + AND r.deletedAt IS NULL + AND CAST(r.completedAt AS date) BETWEEN :startDate AND :endDate + """, + ) + fun countRetrosBetweenDates( + @Param("userId") userId: UUID, + @Param("startDate") startDate: LocalDate, + @Param("endDate") endDate: LocalDate, + ): Int + + // 미션 시작 시각(레벨업 시점) 이후 완료된 회고 수 — 같은 날 레벨업시킨 회고는 제외하기 위해 타임스탬프로 비교 + @Query( + """ + SELECT COUNT(r) FROM Retrospective r + WHERE r.userId = :userId + AND r.deletedAt IS NULL + AND r.completedAt > :startedAt + """, + ) + fun countRetrosAfter( + @Param("userId") userId: UUID, + @Param("startedAt") startedAt: LocalDateTime, + ): Int + + @Query( + """ + SELECT um.* FROM user_missions um + JOIN missions m ON um.mission_id = m.id + WHERE um.status = :status + AND m.level = 2 + AND DATE_ADD(um.started_at, INTERVAL m.duration_days DAY) < CURDATE() + AND um.progress < m.target_count + """, + nativeQuery = true, + ) + fun findExpiredLv2Missions( + @Param("status") status: String = "IN_PROGRESS", + ): List + + @Query( + """ + SELECT um FROM UserMission um + WHERE um.status = :status + AND um.missionId IN ( + SELECT m.id FROM Mission m + WHERE m.level IN (3, 5, 7, 9) + AND m.missionType = :missionType + ) + """, + ) + fun findConsecutiveWeekMissionsInProgress( + @Param("status") status: MissionStatus = MissionStatus.IN_PROGRESS, + @Param("missionType") missionType: MissionType = MissionType.CONSECUTIVE_WEEK, + ): List + + @Query( + "SELECT m.level AS level, um.status AS status, COUNT(um) AS count " + + "FROM UserMission um, Mission m " + + "WHERE um.missionId = m.id " + + "GROUP BY m.level, um.status ORDER BY m.level", + ) + fun countGroupByLevelAndStatus(): List +} + +interface MissionLevelStatusCount { + fun getLevel(): Int + + fun getStatus(): MissionStatus + + fun getCount(): Long +} diff --git a/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt b/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt new file mode 100644 index 00000000..f2181413 --- /dev/null +++ b/src/main/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepository.kt @@ -0,0 +1,11 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.WeeklyRetroStreak +import org.springframework.data.repository.Repository +import java.util.UUID + +interface WeeklyRetroStreakRepository : Repository { + fun findByUserId(userId: UUID): WeeklyRetroStreak? + + fun save(streak: WeeklyRetroStreak): WeeklyRetroStreak +} diff --git a/src/main/kotlin/com/didit/application/admin/AdminAchievementStatsService.kt b/src/main/kotlin/com/didit/application/admin/AdminAchievementStatsService.kt new file mode 100644 index 00000000..bde80d3b --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/AdminAchievementStatsService.kt @@ -0,0 +1,76 @@ +package com.didit.application.admin + +import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.admin.provided.AdminAchievementStatsFinder +import com.didit.application.admin.provided.AdminBadgeStat +import com.didit.application.admin.provided.AdminLevelStat +import com.didit.application.admin.provided.AdminMissionStat +import com.didit.application.auth.required.UserRepository +import com.didit.domain.achievement.MissionStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Transactional(readOnly = true) +@Service +class AdminAchievementStatsService( + private val userLevelRepository: UserLevelRepository, + private val userMissionRepository: UserMissionRepository, + private val badgeRepository: BadgeRepository, + private val userBadgeRepository: UserBadgeRepository, + private val userRepository: UserRepository, +) : AdminAchievementStatsFinder { + companion object { + private const val MIN_LEVEL = 0 + private const val MAX_LEVEL = 10 + } + + override fun getLevelStats(): List { + val countByLevel = userLevelRepository.countGroupByLevel().associate { it.getLevel() to it.getCount() } + val usersWithLevelRow = countByLevel.values.sum() + val usersWithoutLevelRow = (userRepository.countByDeletedAtIsNull() - usersWithLevelRow).coerceAtLeast(0) + return (MIN_LEVEL..MAX_LEVEL).map { level -> + val base = countByLevel[level] ?: 0L + val userCount = if (level == MIN_LEVEL) base + usersWithoutLevelRow else base + AdminLevelStat(level = level, userCount = userCount) + } + } + + override fun getMissionStats(): List { + val rows = userMissionRepository.countGroupByLevelAndStatus() + val byLevel = rows.groupBy { it.getLevel() } + + return byLevel.entries + .sortedBy { it.key } + .map { (level, statusRows) -> + val countByStatus = statusRows.associate { it.getStatus() to it.getCount() } + val inProgress = countByStatus[MissionStatus.IN_PROGRESS] ?: 0L + val completed = countByStatus[MissionStatus.COMPLETED] ?: 0L + val failed = + (countByStatus[MissionStatus.FAILED] ?: 0L) + (countByStatus[MissionStatus.WAIT_CONFIRM] ?: 0L) + val total = inProgress + completed + failed + AdminMissionStat( + level = level, + inProgress = inProgress, + completed = completed, + failed = failed, + total = total, + completionRate = if (total == 0L) 0.0 else completed.toDouble() / total * 100, + ) + } + } + + override fun getBadgeStats(): List = + badgeRepository.findAll().map { badge -> + AdminBadgeStat( + badgeId = badge.id, + name = badge.name, + category = badge.category.name, + conditionType = badge.condition.conditionType.name, + active = badge.active, + acquiredCount = userBadgeRepository.countByBadgeId(badge.id), + ) + } +} diff --git a/src/main/kotlin/com/didit/application/admin/AdminBadgeManagementService.kt b/src/main/kotlin/com/didit/application/admin/AdminBadgeManagementService.kt new file mode 100644 index 00000000..42a8ed48 --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/AdminBadgeManagementService.kt @@ -0,0 +1,140 @@ +package com.didit.application.admin + +import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.admin.provided.AdminBadgeCreateCommand +import com.didit.application.admin.provided.AdminBadgeRegister +import com.didit.application.admin.provided.AdminBadgeResult +import com.didit.application.admin.provided.AdminBadgeUpdateCommand +import com.didit.application.common.exception.BusinessException +import com.didit.application.common.exception.ErrorCode +import com.didit.domain.achievement.Badge +import com.didit.domain.achievement.BadgeCategory +import com.didit.domain.achievement.BadgeCondition +import com.didit.domain.achievement.BadgeConditionType +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Transactional +@Service +class AdminBadgeManagementService( + private val badgeRepository: BadgeRepository, + private val userBadgeRepository: UserBadgeRepository, +) : AdminBadgeRegister { + companion object { + private val SUPPORTED_WEEKLY_MIN_COUNTS = setOf(1, 3) + } + + override fun create(command: AdminBadgeCreateCommand): AdminBadgeResult { + val badge = + Badge.create( + name = command.name, + description = command.description, + category = parseCategory(command.category), + condition = + buildCondition( + conditionType = command.conditionType, + threshold = command.threshold, + params = command.params, + ), + iconUrl = command.iconUrl, + congratsTitle = command.congratsTitle, + congratsMessage = command.congratsMessage, + ) + return badgeRepository.save(badge).toResult() + } + + override fun update( + badgeId: UUID, + command: AdminBadgeUpdateCommand, + ): AdminBadgeResult { + val badge = findBadge(badgeId) + badge.update( + name = command.name, + description = command.description, + category = parseCategory(command.category), + condition = + buildCondition( + conditionType = command.conditionType, + threshold = command.threshold, + params = command.params, + ), + iconUrl = command.iconUrl, + congratsTitle = command.congratsTitle, + congratsMessage = command.congratsMessage, + ) + return badgeRepository.save(badge).toResult() + } + + override fun changeActive( + badgeId: UUID, + active: Boolean, + ): AdminBadgeResult { + val badge = findBadge(badgeId) + badge.changeActive(active) + return badgeRepository.save(badge).toResult() + } + + private fun findBadge(badgeId: UUID): Badge = + badgeRepository.findById(badgeId) + ?: throw BusinessException(ErrorCode.NOT_FOUND, "배지를 찾을 수 없습니다: $badgeId") + + private fun buildCondition( + conditionType: String, + threshold: Int, + params: Map?, + ): BadgeCondition { + if (threshold < 1) { + throw BusinessException(ErrorCode.INVALID_REQUEST, "threshold는 1 이상이어야 합니다: $threshold") + } + val parsedType = parseConditionType(conditionType) + val normalizedParams = params?.takeIf { it.isNotEmpty() } + validateParams(parsedType, normalizedParams) + return BadgeCondition( + conditionType = parsedType, + threshold = threshold, + params = normalizedParams, + ) + } + + private fun validateParams( + conditionType: BadgeConditionType, + params: Map?, + ) { + if (conditionType == BadgeConditionType.WEEKLY_STREAK) { + val weeklyMinCount = (params?.get("weeklyMinCount") as? Number)?.toInt() ?: 1 + if (weeklyMinCount !in SUPPORTED_WEEKLY_MIN_COUNTS) { + throw BusinessException( + ErrorCode.INVALID_REQUEST, + "WEEKLY_STREAK의 weeklyMinCount는 1 또는 3만 지원합니다: $weeklyMinCount", + ) + } + } + } + + private fun parseCategory(value: String): BadgeCategory = + BadgeCategory.entries.find { it.name == value } + ?: throw BusinessException(ErrorCode.INVALID_REQUEST, "유효하지 않은 category 값: $value") + + private fun parseConditionType(value: String): BadgeConditionType = + BadgeConditionType.entries.find { it.name == value } + ?: throw BusinessException(ErrorCode.INVALID_REQUEST, "유효하지 않은 conditionType 값: $value") + + private fun Badge.toResult() = + AdminBadgeResult( + id = id, + name = name, + description = description, + category = category.name, + conditionType = condition.conditionType.name, + threshold = condition.threshold, + params = condition.params, + iconUrl = iconUrl, + congratsTitle = congratsTitle, + congratsMessage = congratsMessage, + active = active, + acquiredCount = userBadgeRepository.countByBadgeId(id), + createdAt = createdAt, + ) +} diff --git a/src/main/kotlin/com/didit/application/admin/AdminBadgeQueryService.kt b/src/main/kotlin/com/didit/application/admin/AdminBadgeQueryService.kt index ebc8a5f3..9b8b1e76 100644 --- a/src/main/kotlin/com/didit/application/admin/AdminBadgeQueryService.kt +++ b/src/main/kotlin/com/didit/application/admin/AdminBadgeQueryService.kt @@ -2,10 +2,19 @@ package com.didit.application.admin import com.didit.application.achievement.required.BadgeRepository import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.admin.provided.AdminBadgeCategoryItem +import com.didit.application.admin.provided.AdminBadgeConditionTypeItem import com.didit.application.admin.provided.AdminBadgeFinder import com.didit.application.admin.provided.AdminBadgeHolder +import com.didit.application.admin.provided.AdminBadgeMetaResult +import com.didit.application.admin.provided.AdminBadgeParamSpec import com.didit.application.admin.provided.AdminBadgeResult import com.didit.application.auth.required.UserRepository +import com.didit.application.common.exception.BusinessException +import com.didit.application.common.exception.ErrorCode +import com.didit.domain.achievement.Badge +import com.didit.domain.achievement.BadgeCategory +import com.didit.domain.achievement.BadgeConditionType import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -17,17 +26,14 @@ class AdminBadgeQueryService( private val userBadgeRepository: UserBadgeRepository, private val userRepository: UserRepository, ) : AdminBadgeFinder { - override fun findAll(): List = - badgeRepository.findAll().map { badge -> - AdminBadgeResult( - id = badge.id, - name = badge.name, - description = badge.description, - conditionType = badge.conditionType.name, - acquiredCount = userBadgeRepository.countByBadgeId(badge.id), - createdAt = badge.createdAt, - ) - } + override fun findAll(): List = badgeRepository.findAll().map { it.toResult() } + + override fun findById(badgeId: UUID): AdminBadgeResult { + val badge = + badgeRepository.findById(badgeId) + ?: throw BusinessException(ErrorCode.NOT_FOUND, "배지를 찾을 수 없습니다: $badgeId") + return badge.toResult() + } override fun findHolders(badgeId: UUID): List { val userBadges = userBadgeRepository.findAllByBadgeId(badgeId) @@ -48,4 +54,86 @@ class AdminBadgeQueryService( ) } } + + override fun findMeta(): AdminBadgeMetaResult = + AdminBadgeMetaResult( + conditionTypes = + BadgeConditionType.entries.map { type -> + AdminBadgeConditionTypeItem( + conditionType = type.name, + label = conditionTypeLabel(type), + description = conditionTypeDescription(type), + params = conditionTypeParams(type), + ) + }, + categories = + BadgeCategory.entries.map { category -> + AdminBadgeCategoryItem( + category = category.name, + label = categoryLabel(category), + ) + }, + ) + + private fun Badge.toResult() = + AdminBadgeResult( + id = id, + name = name, + description = description, + category = category.name, + conditionType = condition.conditionType.name, + threshold = condition.threshold, + params = condition.params, + iconUrl = iconUrl, + congratsTitle = congratsTitle, + congratsMessage = congratsMessage, + active = active, + acquiredCount = userBadgeRepository.countByBadgeId(id), + createdAt = createdAt, + ) + + private fun conditionTypeLabel(type: BadgeConditionType): String = + when (type) { + BadgeConditionType.CUMULATIVE_RETRO -> "누적 회고 수" + BadgeConditionType.WEEKLY_RETRO_COUNT -> "주간 회고 수" + BadgeConditionType.WEEKLY_STREAK -> "연속 주차" + BadgeConditionType.DAILY_ACCESS_STREAK -> "연속 접속일" + BadgeConditionType.PROJECT_COUNT -> "프로젝트 생성 수" + BadgeConditionType.PROJECT_TAGGED_RETRO -> "프로젝트 지정 회고 수" + BadgeConditionType.PROJECT_RETRO_IN_ONE -> "단일 프로젝트 회고 수" + } + + private fun conditionTypeDescription(type: BadgeConditionType): String = + when (type) { + BadgeConditionType.CUMULATIVE_RETRO -> "누적 회고 저장 수가 threshold 이상이면 부여" + BadgeConditionType.WEEKLY_RETRO_COUNT -> "현재 주(월~일) 회고 수가 threshold 이상이면 부여" + BadgeConditionType.WEEKLY_STREAK -> "매주 weeklyMinCount회 이상을 threshold주 연속 달성하면 부여" + BadgeConditionType.DAILY_ACCESS_STREAK -> "연속 접속일이 threshold 이상이면 부여" + BadgeConditionType.PROJECT_COUNT -> "생성한 프로젝트 수가 threshold 이상이면 부여" + BadgeConditionType.PROJECT_TAGGED_RETRO -> "프로젝트가 지정된 회고 수가 threshold 이상이면 부여" + BadgeConditionType.PROJECT_RETRO_IN_ONE -> "한 프로젝트에 속한 회고 수가 threshold 이상이면 부여" + } + + private fun conditionTypeParams(type: BadgeConditionType): List = + when (type) { + BadgeConditionType.WEEKLY_STREAK -> + listOf( + AdminBadgeParamSpec( + key = "weeklyMinCount", + label = "주당 최소 회고 수", + type = "number", + defaultValue = 1, + required = false, + ), + ) + else -> emptyList() + } + + private fun categoryLabel(category: BadgeCategory): String = + when (category) { + BadgeCategory.CONSISTENCY -> "꾸준함" + BadgeCategory.PROJECT -> "프로젝트" + BadgeCategory.PATTERN -> "패턴" + BadgeCategory.ACCESS -> "접속" + } } diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsFinder.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsFinder.kt new file mode 100644 index 00000000..b40d145c --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsFinder.kt @@ -0,0 +1,9 @@ +package com.didit.application.admin.provided + +interface AdminAchievementStatsFinder { + fun getLevelStats(): List + + fun getMissionStats(): List + + fun getBadgeStats(): List +} diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsResult.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsResult.kt new file mode 100644 index 00000000..d4dfca96 --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminAchievementStatsResult.kt @@ -0,0 +1,26 @@ +package com.didit.application.admin.provided + +import java.util.UUID + +data class AdminLevelStat( + val level: Int, + val userCount: Long, +) + +data class AdminMissionStat( + val level: Int, + val inProgress: Long, + val completed: Long, + val failed: Long, + val total: Long, + val completionRate: Double, +) + +data class AdminBadgeStat( + val badgeId: UUID, + val name: String, + val category: String, + val conditionType: String, + val active: Boolean, + val acquiredCount: Long, +) diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeCommand.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeCommand.kt new file mode 100644 index 00000000..d45abcf2 --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeCommand.kt @@ -0,0 +1,25 @@ +package com.didit.application.admin.provided + +data class AdminBadgeCreateCommand( + val name: String, + val description: String, + val category: String, + val conditionType: String, + val threshold: Int, + val params: Map? = null, + val iconUrl: String? = null, + val congratsTitle: String? = null, + val congratsMessage: String? = null, +) + +data class AdminBadgeUpdateCommand( + val name: String, + val description: String, + val category: String, + val conditionType: String, + val threshold: Int, + val params: Map? = null, + val iconUrl: String? = null, + val congratsTitle: String? = null, + val congratsMessage: String? = null, +) diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeFinder.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeFinder.kt index 2fc6251b..f208fb39 100644 --- a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeFinder.kt +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeFinder.kt @@ -5,5 +5,9 @@ import java.util.UUID interface AdminBadgeFinder { fun findAll(): List + fun findById(badgeId: UUID): AdminBadgeResult + fun findHolders(badgeId: UUID): List + + fun findMeta(): AdminBadgeMetaResult } diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeMetaResult.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeMetaResult.kt new file mode 100644 index 00000000..3232ac46 --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeMetaResult.kt @@ -0,0 +1,26 @@ +package com.didit.application.admin.provided + +data class AdminBadgeMetaResult( + val conditionTypes: List, + val categories: List, +) + +data class AdminBadgeConditionTypeItem( + val conditionType: String, + val label: String, + val description: String, + val params: List, +) + +data class AdminBadgeParamSpec( + val key: String, + val label: String, + val type: String, + val defaultValue: Any?, + val required: Boolean, +) + +data class AdminBadgeCategoryItem( + val category: String, + val label: String, +) diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeRegister.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeRegister.kt new file mode 100644 index 00000000..21c259fc --- /dev/null +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeRegister.kt @@ -0,0 +1,17 @@ +package com.didit.application.admin.provided + +import java.util.UUID + +interface AdminBadgeRegister { + fun create(command: AdminBadgeCreateCommand): AdminBadgeResult + + fun update( + badgeId: UUID, + command: AdminBadgeUpdateCommand, + ): AdminBadgeResult + + fun changeActive( + badgeId: UUID, + active: Boolean, + ): AdminBadgeResult +} diff --git a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeResult.kt b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeResult.kt index fe1295a3..f907cb12 100644 --- a/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeResult.kt +++ b/src/main/kotlin/com/didit/application/admin/provided/AdminBadgeResult.kt @@ -7,7 +7,14 @@ data class AdminBadgeResult( val id: UUID, val name: String, val description: String, + val category: String, val conditionType: String, + val threshold: Int, + val params: Map?, + val iconUrl: String?, + val congratsTitle: String?, + val congratsMessage: String?, + val active: Boolean, val acquiredCount: Long, val createdAt: LocalDateTime?, ) diff --git a/src/main/kotlin/com/didit/application/audit/AuditAction.kt b/src/main/kotlin/com/didit/application/audit/AuditAction.kt index 957b57eb..8760fff0 100644 --- a/src/main/kotlin/com/didit/application/audit/AuditAction.kt +++ b/src/main/kotlin/com/didit/application/audit/AuditAction.kt @@ -36,6 +36,10 @@ enum class AuditAction( BADGE_ACQUIRED("배지 획득", ActorType.SYSTEM), + ADMIN_BADGE_CREATED("배지 생성", ActorType.ADMIN), + ADMIN_BADGE_UPDATED("배지 수정", ActorType.ADMIN), + ADMIN_BADGE_ACTIVE_CHANGED("배지 활성 상태 변경", ActorType.ADMIN), + PROJECT_CREATED("프로젝트 생성", ActorType.USER), PROJECT_UPDATED("프로젝트 수정", ActorType.USER), PROJECT_DELETED("프로젝트 삭제", ActorType.USER), diff --git a/src/main/kotlin/com/didit/application/auth/AuthService.kt b/src/main/kotlin/com/didit/application/auth/AuthService.kt index 182a1218..86dd9bc8 100644 --- a/src/main/kotlin/com/didit/application/auth/AuthService.kt +++ b/src/main/kotlin/com/didit/application/auth/AuthService.kt @@ -18,10 +18,13 @@ import com.didit.application.notification.required.DeviceTokenRepository import com.didit.domain.auth.Provider import com.didit.domain.auth.RefreshToken import com.didit.domain.auth.User +import com.didit.domain.auth.UserLoggedInEvent import com.didit.domain.auth.UserRegisterRequest import com.didit.domain.auth.WithdrawalReason import com.didit.domain.auth.WithdrawalRecord +import com.didit.domain.shared.ServiceTime import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.UUID @@ -37,6 +40,7 @@ class AuthService( private val withdrawalRecordRepository: WithdrawalRecordRepository, private val auditLogger: AuditLogger, private val deviceTokenRepository: DeviceTokenRepository, + private val eventPublisher: ApplicationEventPublisher, ) : Auth { companion object { private val logger = LoggerFactory.getLogger(AuthService::class.java) @@ -62,6 +66,13 @@ class AuthService( payload = mapOf("provider" to provider.name), ) + eventPublisher.publishEvent( + UserLoggedInEvent( + userId = user.id, + accessDateKst = ServiceTime.today(), + ), + ) + return issueTokens(user, isNewUser) } diff --git a/src/main/kotlin/com/didit/application/notification/NotificationHistoryQueryService.kt b/src/main/kotlin/com/didit/application/notification/NotificationHistoryQueryService.kt index dc945366..af785b79 100644 --- a/src/main/kotlin/com/didit/application/notification/NotificationHistoryQueryService.kt +++ b/src/main/kotlin/com/didit/application/notification/NotificationHistoryQueryService.kt @@ -18,4 +18,10 @@ class NotificationHistoryQueryService( userId = userId, createdAt = LocalDateTime.now().minusDays(NotificationHistory.RETENTION_DAYS), ) + + override fun hasUnread(userId: UUID): Boolean = + notificationHistoryRepository.existsByUserIdAndIsReadFalseAndCreatedAtAfter( + userId = userId, + createdAt = LocalDateTime.now().minusDays(NotificationHistory.RETENTION_DAYS), + ) } diff --git a/src/main/kotlin/com/didit/application/notification/provided/NotificationHistoryFinder.kt b/src/main/kotlin/com/didit/application/notification/provided/NotificationHistoryFinder.kt index 10d1adf1..22661eaa 100644 --- a/src/main/kotlin/com/didit/application/notification/provided/NotificationHistoryFinder.kt +++ b/src/main/kotlin/com/didit/application/notification/provided/NotificationHistoryFinder.kt @@ -5,4 +5,6 @@ import java.util.UUID interface NotificationHistoryFinder { fun findAllByUserId(userId: UUID): List + + fun hasUnread(userId: UUID): Boolean } diff --git a/src/main/kotlin/com/didit/application/notification/required/NotificationHistoryRepository.kt b/src/main/kotlin/com/didit/application/notification/required/NotificationHistoryRepository.kt index 84ddbe01..caf857ec 100644 --- a/src/main/kotlin/com/didit/application/notification/required/NotificationHistoryRepository.kt +++ b/src/main/kotlin/com/didit/application/notification/required/NotificationHistoryRepository.kt @@ -16,6 +16,11 @@ interface NotificationHistoryRepository : Repository createdAt: LocalDateTime, ): List + fun existsByUserIdAndIsReadFalseAndCreatedAtAfter( + userId: UUID, + createdAt: LocalDateTime, + ): Boolean + fun findByIdAndUserId( id: UUID, userId: UUID, diff --git a/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt b/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt index b3919a60..23ace670 100644 --- a/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt +++ b/src/main/kotlin/com/didit/application/retrospect/required/RetrospectiveRepository.kt @@ -225,4 +225,19 @@ interface RetrospectiveRepository : Repository { fun countUserAnswersByInputType( @Param("inputType") inputType: InputType, ): Long + + @Query( + """ + SELECT COUNT(r) FROM Retrospective r + WHERE r.userId = :userId + AND r.status = 'COMPLETED' + AND r.deletedAt IS NULL + AND r.completedAt >= :from AND r.completedAt < :to + """, + ) + fun countCompletedByUserIdAndPeriod( + @Param("userId") userId: UUID, + @Param("from") from: LocalDateTime, + @Param("to") to: LocalDateTime, + ): Int } diff --git a/src/main/kotlin/com/didit/domain/achievement/Badge.kt b/src/main/kotlin/com/didit/domain/achievement/Badge.kt index 958f1112..878ab669 100644 --- a/src/main/kotlin/com/didit/domain/achievement/Badge.kt +++ b/src/main/kotlin/com/didit/domain/achievement/Badge.kt @@ -1,6 +1,7 @@ package com.didit.domain.achievement import jakarta.persistence.Column +import jakarta.persistence.Embedded import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated @@ -16,22 +17,70 @@ class Badge( @Column(columnDefinition = "BINARY(16)") val id: UUID = UUID.randomUUID(), @Column(nullable = false, length = 50) - val name: String, + var name: String, @Column(nullable = false, length = 255) - val description: String, + var description: String, @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 50) - val conditionType: BadgeConditionType, + @Column(nullable = false, length = 30) + var category: BadgeCategory, + @Embedded + var condition: BadgeCondition, + @Column(name = "icon_url", length = 500) + var iconUrl: String? = null, + @Column(name = "congrats_title", length = 100) + var congratsTitle: String? = null, + @Column(name = "congrats_message", length = 255) + var congratsMessage: String? = null, + @Column(nullable = false) + var active: Boolean = true, @Column(nullable = false) val createdAt: LocalDateTime = LocalDateTime.now(), ) { - fun isSameCondition(conditionType: BadgeConditionType): Boolean = this.conditionType == conditionType + val conditionType: BadgeConditionType + get() = condition.conditionType + + fun isSameCondition(conditionType: BadgeConditionType): Boolean = this.condition.conditionType == conditionType + + fun update( + name: String, + description: String, + category: BadgeCategory, + condition: BadgeCondition, + iconUrl: String?, + congratsTitle: String?, + congratsMessage: String?, + ) { + this.name = name + this.description = description + this.category = category + this.condition = condition + this.iconUrl = iconUrl + this.congratsTitle = congratsTitle + this.congratsMessage = congratsMessage + } + + fun changeActive(active: Boolean) { + this.active = active + } companion object { fun create( name: String, description: String, - conditionType: BadgeConditionType, - ): Badge = Badge(name = name, description = description, conditionType = conditionType) + category: BadgeCategory, + condition: BadgeCondition, + iconUrl: String? = null, + congratsTitle: String? = null, + congratsMessage: String? = null, + ): Badge = + Badge( + name = name, + description = description, + category = category, + condition = condition, + iconUrl = iconUrl, + congratsTitle = congratsTitle, + congratsMessage = congratsMessage, + ) } } diff --git a/src/main/kotlin/com/didit/domain/achievement/BadgeCategory.kt b/src/main/kotlin/com/didit/domain/achievement/BadgeCategory.kt new file mode 100644 index 00000000..c2ca0ad3 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/BadgeCategory.kt @@ -0,0 +1,8 @@ +package com.didit.domain.achievement + +enum class BadgeCategory { + CONSISTENCY, + PROJECT, + PATTERN, + ACCESS, +} diff --git a/src/main/kotlin/com/didit/domain/achievement/BadgeCondition.kt b/src/main/kotlin/com/didit/domain/achievement/BadgeCondition.kt new file mode 100644 index 00000000..2e75ae2c --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/BadgeCondition.kt @@ -0,0 +1,22 @@ +package com.didit.domain.achievement + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes + +@Embeddable +class BadgeCondition( + @Enumerated(EnumType.STRING) + @Column(name = "condition_type", nullable = false, length = 50) + val conditionType: BadgeConditionType, + @Column(name = "threshold", nullable = false) + val threshold: Int, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "params", columnDefinition = "JSON") + val params: Map? = null, +) { + fun weeklyMinCount(): Int = (params?.get("weeklyMinCount") as? Number)?.toInt() ?: 1 +} diff --git a/src/main/kotlin/com/didit/domain/achievement/BadgeConditionType.kt b/src/main/kotlin/com/didit/domain/achievement/BadgeConditionType.kt index b5476955..b56048a9 100644 --- a/src/main/kotlin/com/didit/domain/achievement/BadgeConditionType.kt +++ b/src/main/kotlin/com/didit/domain/achievement/BadgeConditionType.kt @@ -1,14 +1,11 @@ package com.didit.domain.achievement enum class BadgeConditionType { - FIRST_RETRO, - STREAK_3_DAYS, - TOTAL_30, - - DEEP_QUESTION_1, - DEEP_QUESTION_5, - DEEP_QUESTION_10, - - WEEKLY_3_FIRST, - WEEKLY_3_THREE_WEEKS, + CUMULATIVE_RETRO, + WEEKLY_RETRO_COUNT, + WEEKLY_STREAK, + DAILY_ACCESS_STREAK, + PROJECT_COUNT, + PROJECT_TAGGED_RETRO, + PROJECT_RETRO_IN_ONE, } diff --git a/src/main/kotlin/com/didit/domain/achievement/Streak.kt b/src/main/kotlin/com/didit/domain/achievement/DailyAccessStreak.kt similarity index 54% rename from src/main/kotlin/com/didit/domain/achievement/Streak.kt rename to src/main/kotlin/com/didit/domain/achievement/DailyAccessStreak.kt index 0d3edd59..cd299e24 100644 --- a/src/main/kotlin/com/didit/domain/achievement/Streak.kt +++ b/src/main/kotlin/com/didit/domain/achievement/DailyAccessStreak.kt @@ -8,9 +8,9 @@ import java.time.LocalDate import java.time.LocalDateTime import java.util.UUID -@Table(name = "streaks") +@Table(name = "daily_access_streaks") @Entity -class Streak( +class DailyAccessStreak( @Id @Column(columnDefinition = "BINARY(16)") val userId: UUID, @@ -19,37 +19,37 @@ class Streak( @Column(nullable = false) var longestStreak: Int = 0, @Column - var lastRetroDate: LocalDate? = null, + var lastAccessDate: LocalDate? = null, @Column(nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now(), ) { - fun update(retroDate: LocalDate) { - if (isSameDay(retroDate)) return - if (isConsecutive(retroDate)) return increaseStreak(retroDate) - resetStreak(retroDate) + fun recordAccess(accessDateKst: LocalDate) { + if (isSameDay(accessDateKst)) return + if (isConsecutive(accessDateKst)) return increaseStreak(accessDateKst) + resetStreak(accessDateKst) } fun isStreak(days: Int): Boolean = currentStreak >= days - private fun isSameDay(retroDate: LocalDate): Boolean = lastRetroDate == retroDate + private fun isSameDay(accessDate: LocalDate): Boolean = lastAccessDate == accessDate - private fun isConsecutive(retroDate: LocalDate): Boolean = lastRetroDate?.plusDays(1) == retroDate + private fun isConsecutive(accessDate: LocalDate): Boolean = lastAccessDate?.plusDays(1) == accessDate - private fun increaseStreak(retroDate: LocalDate) { + private fun increaseStreak(accessDate: LocalDate) { currentStreak += 1 longestStreak = maxOf(longestStreak, currentStreak) - lastRetroDate = retroDate + lastAccessDate = accessDate updatedAt = LocalDateTime.now() } - private fun resetStreak(retroDate: LocalDate) { + private fun resetStreak(accessDate: LocalDate) { currentStreak = 1 longestStreak = maxOf(longestStreak, 1) - lastRetroDate = retroDate + lastAccessDate = accessDate updatedAt = LocalDateTime.now() } companion object { - fun create(userId: UUID): Streak = Streak(userId = userId) + fun create(userId: UUID): DailyAccessStreak = DailyAccessStreak(userId = userId) } } diff --git a/src/main/kotlin/com/didit/domain/achievement/Mission.kt b/src/main/kotlin/com/didit/domain/achievement/Mission.kt new file mode 100644 index 00000000..99f64aad --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/Mission.kt @@ -0,0 +1,84 @@ +package com.didit.domain.achievement + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "missions") +@Entity +class Mission( + @Id + @Column(columnDefinition = "BINARY(16)") + val id: UUID = UUID.randomUUID(), + @Column(nullable = false) + val level: Int, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + val missionType: MissionType, + @Column(nullable = false) + val targetCount: Int, + @Column(nullable = false, length = 100) + val title: String, + @Column(nullable = false, length = 255) + val description: String, + @Column(length = 255) + val subText: String? = null, + @Column + val durationDays: Int? = null, + @Column(nullable = false) + val isActive: Boolean = true, + @Column(nullable = false) + val createdAt: LocalDateTime = LocalDateTime.now(), +) { + companion object { + fun firstRetro(): Mission = + Mission( + level = 1, + missionType = MissionType.FIRST_RETRO, + targetCount = 1, + title = "첫 회고 작성하기", + description = "첫 회고를 작성해보세요", + ) + + fun timeLimited(): Mission = + Mission( + level = 2, + missionType = MissionType.TIME_LIMITED, + targetCount = 3, + title = "일주일 내에 회고 3회 작성하기", + description = "7일 이내에 회고를 3회 이상 작성해보세요", + durationDays = 7, + ) + + fun consecutiveWeek( + level: Int, + weeks: Int, + ): Mission = + Mission( + level = level, + missionType = MissionType.CONSECUTIVE_WEEK, + targetCount = weeks, + title = "${weeks}주 연속 주 1회 이상 회고", + description = "매주 한 번씩 회고를 작성하면 달성할 수 있어요", + subText = "꾸준한 기록이 습관을 만들어요", + ) + + fun cumulativeRetro( + level: Int, + count: Int, + ): Mission = + Mission( + level = level, + missionType = MissionType.CUMULATIVE_RETRO, + targetCount = count, + title = "회고 ${count}회 작성하기", + description = "총 ${count}회의 회고를 작성해보세요", + subText = "원하는 속도로 미션을 완료해보세요", + ) + } +} diff --git a/src/main/kotlin/com/didit/domain/achievement/MissionStatus.kt b/src/main/kotlin/com/didit/domain/achievement/MissionStatus.kt new file mode 100644 index 00000000..815d2ed2 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/MissionStatus.kt @@ -0,0 +1,8 @@ +package com.didit.domain.achievement + +enum class MissionStatus { + IN_PROGRESS, + COMPLETED, + FAILED, + WAIT_CONFIRM, +} diff --git a/src/main/kotlin/com/didit/domain/achievement/MissionType.kt b/src/main/kotlin/com/didit/domain/achievement/MissionType.kt new file mode 100644 index 00000000..32d14cdd --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/MissionType.kt @@ -0,0 +1,8 @@ +package com.didit.domain.achievement + +enum class MissionType { + FIRST_RETRO, + TIME_LIMITED, + CONSECUTIVE_WEEK, + CUMULATIVE_RETRO, +} diff --git a/src/main/kotlin/com/didit/domain/achievement/UserLevel.kt b/src/main/kotlin/com/didit/domain/achievement/UserLevel.kt new file mode 100644 index 00000000..6e57616d --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/UserLevel.kt @@ -0,0 +1,31 @@ +package com.didit.domain.achievement + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "user_levels") +@Entity +class UserLevel( + @Id + @Column(columnDefinition = "BINARY(16)") + val userId: UUID, + @Column(nullable = false) + var currentLevel: Int = 0, + @Column(nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now(), +) { + fun levelUp() { + if (currentLevel < 10) { + currentLevel += 1 + updatedAt = LocalDateTime.now() + } + } + + companion object { + fun create(userId: UUID): UserLevel = UserLevel(userId = userId) + } +} diff --git a/src/main/kotlin/com/didit/domain/achievement/UserMission.kt b/src/main/kotlin/com/didit/domain/achievement/UserMission.kt new file mode 100644 index 00000000..d8fb66b3 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/UserMission.kt @@ -0,0 +1,80 @@ +package com.didit.domain.achievement + +import com.didit.domain.shared.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "user_missions") +@Entity +class UserMission( + @Id + @Column(columnDefinition = "BINARY(16)") + val id: UUID = UUID.randomUUID(), + @Column(nullable = false, columnDefinition = "BINARY(16)") + val userId: UUID, + @Column(nullable = false, columnDefinition = "BINARY(16)") + val missionId: UUID, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: MissionStatus = MissionStatus.IN_PROGRESS, + @Column(nullable = false) + var progress: Int = 0, + @Column + var lastRetroDate: LocalDate? = null, + @Column(nullable = false) + var failureCount: Int = 0, + @Column(nullable = false) + var levelUpPopupShown: Boolean = false, + @Column(nullable = false) + var failurePopupShown: Boolean = false, + @Column(name = "started_at", nullable = false) + var startedAt: LocalDateTime = LocalDateTime.now(), + @Column(name = "completed_at") + var completedAt: LocalDateTime? = null, +) : BaseEntity() { + fun complete() { + this.status = MissionStatus.COMPLETED + this.completedAt = LocalDateTime.now() + } + + fun fail() { + this.status = MissionStatus.FAILED + this.failureCount += 1 + } + + fun resetProgress() { + this.progress = 0 + this.failureCount += 1 + } + + fun retry() { + this.status = MissionStatus.IN_PROGRESS + this.progress = 0 + this.failureCount += 1 + this.completedAt = null + this.lastRetroDate = null + } + + fun incrementProgress() { + this.progress += 1 + } + + fun setFailureWaitingConfirm() { + this.status = MissionStatus.WAIT_CONFIRM + this.failurePopupShown = false + } + + companion object { + fun create( + userId: UUID, + missionId: UUID, + ): UserMission = UserMission(userId = userId, missionId = missionId) + } +} diff --git a/src/main/kotlin/com/didit/domain/achievement/WeeklyRetroStreak.kt b/src/main/kotlin/com/didit/domain/achievement/WeeklyRetroStreak.kt new file mode 100644 index 00000000..7b950529 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/achievement/WeeklyRetroStreak.kt @@ -0,0 +1,59 @@ +package com.didit.domain.achievement + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID + +@Table(name = "weekly_retro_streaks") +@Entity +class WeeklyRetroStreak( + @Id + @Column(columnDefinition = "BINARY(16)") + val userId: UUID, + @Column(nullable = false) + var currentWeeks: Int = 0, + @Column(nullable = false) + var longestWeeks: Int = 0, + @Column + var lastAchievedWeek: LocalDate? = null, + @Column(nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now(), +) { + fun recordRetro(retroDateKst: LocalDate) { + val weekStart = weekStartOf(retroDateKst) + if (isSameWeek(weekStart)) return + if (isNextWeek(weekStart)) return increaseStreak(weekStart) + resetStreak(weekStart) + } + + fun isStreak(weeks: Int): Boolean = currentWeeks >= weeks + + private fun isSameWeek(weekStart: LocalDate): Boolean = lastAchievedWeek == weekStart + + private fun isNextWeek(weekStart: LocalDate): Boolean = lastAchievedWeek?.plusWeeks(1) == weekStart + + private fun increaseStreak(weekStart: LocalDate) { + currentWeeks += 1 + longestWeeks = maxOf(longestWeeks, currentWeeks) + lastAchievedWeek = weekStart + updatedAt = LocalDateTime.now() + } + + private fun resetStreak(weekStart: LocalDate) { + currentWeeks = 1 + longestWeeks = maxOf(longestWeeks, 1) + lastAchievedWeek = weekStart + updatedAt = LocalDateTime.now() + } + + private fun weekStartOf(date: LocalDate): LocalDate = date.minusDays((date.dayOfWeek.value - DayOfWeek.MONDAY.value).toLong()) + + companion object { + fun create(userId: UUID): WeeklyRetroStreak = WeeklyRetroStreak(userId = userId) + } +} diff --git a/src/main/kotlin/com/didit/domain/auth/UserLoggedInEvent.kt b/src/main/kotlin/com/didit/domain/auth/UserLoggedInEvent.kt new file mode 100644 index 00000000..dc338586 --- /dev/null +++ b/src/main/kotlin/com/didit/domain/auth/UserLoggedInEvent.kt @@ -0,0 +1,9 @@ +package com.didit.domain.auth + +import java.time.LocalDate +import java.util.UUID + +data class UserLoggedInEvent( + val userId: UUID, + val accessDateKst: LocalDate, +) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dd69ed63..f951941b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -43,6 +43,9 @@ jwt: access-token-expiry-ms: 3600000 refresh-token-expiry-days: 14 +achievement: + feature-launched-at: ${ACHIEVEMENT_FEATURE_LAUNCHED_AT:2026-06-30T00:00:00} + clova: api: url: https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX-005 diff --git a/src/main/resources/db/migration/V35__create_mission_tables.sql b/src/main/resources/db/migration/V35__create_mission_tables.sql new file mode 100644 index 00000000..4a7c8a01 --- /dev/null +++ b/src/main/resources/db/migration/V35__create_mission_tables.sql @@ -0,0 +1,50 @@ +CREATE TABLE missions +( + id BINARY(16) PRIMARY KEY, + level INT NOT NULL, + mission_type VARCHAR(30) NOT NULL, + target_count INT NOT NULL, + title VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL, + sub_text VARCHAR(255), + duration_days INT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL, + + UNIQUE KEY uk_level (level), + INDEX idx_mission_type (mission_type), + INDEX idx_is_active (is_active) +); + + +CREATE TABLE user_levels +( + user_id BINARY(16) PRIMARY KEY, + current_level INT NOT NULL DEFAULT 1, + updated_at DATETIME NOT NULL, + + INDEX idx_current_level (current_level) +); + + +CREATE TABLE user_missions +( + id BINARY(16) PRIMARY KEY, + user_id BINARY(16) NOT NULL, + mission_id BINARY(16) NOT NULL, + status VARCHAR(20) NOT NULL, + progress INT NOT NULL DEFAULT 0, + last_retro_date DATE, + failure_count INT NOT NULL DEFAULT 0, + level_up_popup_shown BOOLEAN NOT NULL DEFAULT FALSE, + failure_popup_shown BOOLEAN NOT NULL DEFAULT FALSE, + started_at DATETIME NOT NULL, + completed_at DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + + INDEX idx_user_id (user_id), + INDEX idx_mission_id (mission_id), + INDEX idx_status (status), + INDEX idx_user_mission (user_id, status) +); diff --git a/src/main/resources/db/migration/V36__insert_missions.sql b/src/main/resources/db/migration/V36__insert_missions.sql new file mode 100644 index 00000000..b07eaa4c --- /dev/null +++ b/src/main/resources/db/migration/V36__insert_missions.sql @@ -0,0 +1,11 @@ +INSERT INTO missions (id, level, mission_type, target_count, title, description, sub_text, duration_days, is_active, created_at) +VALUES (UUID_TO_BIN(UUID()), 1, 'FIRST_RETRO', 1, '첫 회고 작성하기', '첫 회고를 작성해보세요', NULL, NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 2, 'TIME_LIMITED', 3, '일주일 내에 회고 3회 작성하기', '7일 이내에 회고를 3회 이상 작성해보세요', NULL, 7, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 3, 'CONSECUTIVE_WEEK', 2, '2주 연속 주 1회 이상 회고', '매주 한 번씩 회고를 작성하면 달성할 수 있어요', '꾸준한 기록이 습관을 만들어요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 4, 'CUMULATIVE_RETRO', 3, '회고 3회 작성하기', '총 3회의 회고를 작성해보세요', '원하는 속도로 미션을 완료해보세요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 5, 'CONSECUTIVE_WEEK', 3, '3주 연속 주 1회 이상 회고', '매주 한 번씩 회고를 작성하면 달성할 수 있어요', '꾸준한 기록이 습관을 만들어요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 6, 'CUMULATIVE_RETRO', 5, '회고 5회 작성하기', '총 5회의 회고를 작성해보세요', '원하는 속도로 미션을 완료해보세요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 7, 'CONSECUTIVE_WEEK', 4, '4주 연속 주 1회 이상 회고', '매주 한 번씩 회고를 작성하면 달성할 수 있어요', '꾸준한 기록이 습관을 만들어요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 8, 'CUMULATIVE_RETRO', 7, '회고 7회 작성하기', '총 7회의 회고를 작성해보세요', '원하는 속도로 미션을 완료해보세요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 9, 'CONSECUTIVE_WEEK', 5, '5주 연속 주 1회 이상 회고', '매주 한 번씩 회고를 작성하면 달성할 수 있어요', '꾸준한 기록이 습관을 만들어요', NULL, TRUE, NOW()), + (UUID_TO_BIN(UUID()), 10, 'CUMULATIVE_RETRO', 10, '회고 10회 작성하기', '총 10회의 회고를 작성해보세요', '원하는 속도로 미션을 완료해보세요', NULL, TRUE, NOW()); diff --git a/src/main/resources/db/migration/V37__expand_badges_schema.sql b/src/main/resources/db/migration/V37__expand_badges_schema.sql new file mode 100644 index 00000000..615a9056 --- /dev/null +++ b/src/main/resources/db/migration/V37__expand_badges_schema.sql @@ -0,0 +1,8 @@ +ALTER TABLE badges + ADD COLUMN category VARCHAR(30) NULL AFTER condition_type, + ADD COLUMN threshold INT NULL AFTER category, + ADD COLUMN params JSON NULL AFTER threshold, + ADD COLUMN icon_url VARCHAR(500) NULL AFTER params, + ADD COLUMN congrats_title VARCHAR(100) NULL AFTER icon_url, + ADD COLUMN congrats_message VARCHAR(255) NULL AFTER congrats_title, + ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 AFTER congrats_message; diff --git a/src/main/resources/db/migration/V38__split_streaks.sql b/src/main/resources/db/migration/V38__split_streaks.sql new file mode 100644 index 00000000..8ef0fbd3 --- /dev/null +++ b/src/main/resources/db/migration/V38__split_streaks.sql @@ -0,0 +1,19 @@ +-- 기존 streaks 테이블은 페이즈 3(평가기 재작성)에서 DROP. 이 마이그레이션은 신규 두 테이블 추가만. + +CREATE TABLE weekly_retro_streaks +( + user_id BINARY(16) PRIMARY KEY, + current_weeks INT NOT NULL DEFAULT 0, + longest_weeks INT NOT NULL DEFAULT 0, + last_achieved_week DATE, + updated_at DATETIME NOT NULL +); + +CREATE TABLE daily_access_streaks +( + user_id BINARY(16) PRIMARY KEY, + current_streak INT NOT NULL DEFAULT 0, + longest_streak INT NOT NULL DEFAULT 0, + last_access_date DATE, + updated_at DATETIME NOT NULL +); diff --git a/src/main/resources/db/migration/V39__redefine_badges_seed.sql b/src/main/resources/db/migration/V39__redefine_badges_seed.sql new file mode 100644 index 00000000..3f7e7a77 --- /dev/null +++ b/src/main/resources/db/migration/V39__redefine_badges_seed.sql @@ -0,0 +1,24 @@ +-- 1.2.0 배지 10종 재정의. 기존 시드(STREAK_3_DAYS, DEEP_QUESTION_x, WEEKLY_3_FIRST 등)는 신규 conditionType과 의미가 달라 DELETE 후 재시드. +-- UserBadge 데이터는 보존(badge_id orphan은 BadgeQueryService에서 mapNotNull로 안전 처리). + +DELETE FROM badges; + +INSERT INTO badges (id, name, description, condition_type, category, threshold, params, icon_url, congrats_title, congrats_message, active, created_at) +VALUES + -- 꾸준함 + (UUID_TO_BIN('ba001111-1111-1111-1111-000000000001'), '첫 기록', '첫 회고 저장 완료', 'CUMULATIVE_RETRO', 'CONSISTENCY', 1, NULL, NULL, '첫 기록 달성!', '첫 회고를 남겼어요', 1, NOW()), + (UUID_TO_BIN('ba001111-1111-1111-1111-000000000002'), '10회 기록', '누적 회고 10회 달성', 'CUMULATIVE_RETRO', 'CONSISTENCY', 10, NULL, NULL, '10회 기록 달성!', '회고 10회를 채웠어요', 1, NOW()), + (UUID_TO_BIN('ba001111-1111-1111-1111-000000000003'), '30회 기록', '누적 회고 30회 달성', 'CUMULATIVE_RETRO', 'CONSISTENCY', 30, NULL, NULL, '30회 기록 달성!', '회고 30회를 채웠어요', 1, NOW()), + + -- 프로젝트 + (UUID_TO_BIN('ba001111-2222-2222-2222-000000000001'), '컬렉터', '프로젝트 3개 생성', 'PROJECT_COUNT', 'PROJECT', 3, NULL, NULL, '컬렉터 달성!', '프로젝트 3개를 만들었어요', 1, NOW()), + (UUID_TO_BIN('ba001111-2222-2222-2222-000000000002'), '피커', '지정된 회고 5회 작성', 'PROJECT_TAGGED_RETRO', 'PROJECT', 5, NULL, NULL, '피커 달성!', '지정 회고를 5회 작성했어요', 1, NOW()), + (UUID_TO_BIN('ba001111-2222-2222-2222-000000000003'), '디깅', '한 프로젝트에 회고 3회 작성','PROJECT_RETRO_IN_ONE', 'PROJECT', 3, NULL, NULL, '디깅 달성!', '한 프로젝트에 회고 3회 완료', 1, NOW()), + + -- 패턴 + (UUID_TO_BIN('ba001111-3333-3333-3333-000000000001'), '루틴 첫걸음', '한 주에 회고 3회 작성', 'WEEKLY_RETRO_COUNT', 'PATTERN', 3, NULL, NULL, '루틴 첫걸음 달성!', '한 주에 회고 3회를 채웠어요', 1, NOW()), + (UUID_TO_BIN('ba001111-3333-3333-3333-000000000002'), '루틴의 힘', '3주간 매주 3회 회고', 'WEEKLY_STREAK', 'PATTERN', 3, JSON_OBJECT('weeklyMinCount', 3), NULL, '루틴의 힘 달성!', '3주 연속 주 3회 완료', 1, NOW()), + (UUID_TO_BIN('ba001111-3333-3333-3333-000000000003'), '루틴의 지속', '4주 연속 주 1회 이상 회고', 'WEEKLY_STREAK', 'PATTERN', 4, JSON_OBJECT('weeklyMinCount', 1), NULL, '루틴의 지속 달성!', '4주 연속 회고를 이어갔어요', 1, NOW()), + + -- 접속 + (UUID_TO_BIN('ba001111-4444-4444-4444-000000000001'), '디딧 러버', '7일 연속 접속', 'DAILY_ACCESS_STREAK', 'ACCESS', 7, NULL, NULL, '디딧 러버 달성!', '7일 연속 접속했어요', 1, NOW()); diff --git a/src/main/resources/db/migration/V40__drop_streaks.sql b/src/main/resources/db/migration/V40__drop_streaks.sql new file mode 100644 index 00000000..bdbd29f6 --- /dev/null +++ b/src/main/resources/db/migration/V40__drop_streaks.sql @@ -0,0 +1,5 @@ +-- 회고 일별 연속 스트릭(streaks)은 1.2.0에서 폐기. +-- WeeklyRetroStreak(주간 회고 연속) + DailyAccessStreak(일일 접속 연속) 두 도메인으로 대체됨 (V35). +-- 소급 없음 정책에 따라 기존 데이터 보존 불필요. + +DROP TABLE IF EXISTS streaks; diff --git a/src/main/resources/db/migration/V41__add_notification_unread_index.sql b/src/main/resources/db/migration/V41__add_notification_unread_index.sql new file mode 100644 index 00000000..6e7acad5 --- /dev/null +++ b/src/main/resources/db/migration/V41__add_notification_unread_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_notification_histories_unread + ON notification_histories (user_id, is_read, created_at); diff --git a/src/main/resources/db/migration/V42__cleanup_orphan_user_badges.sql b/src/main/resources/db/migration/V42__cleanup_orphan_user_badges.sql new file mode 100644 index 00000000..5fea498d --- /dev/null +++ b/src/main/resources/db/migration/V42__cleanup_orphan_user_badges.sql @@ -0,0 +1,5 @@ +-- 1.2.0 배지 재정의(V39) 이후 남은 구 배지 획득 기록 정리. +-- 현재 badges 테이블에 존재하지 않는 badge_id를 가진 user_badges(=구 스트릭 시절 획득분)를 삭제한다. +-- 배포 시점부터 신규 배지는 소급 없이 다음 트리거 이벤트부터 평가/부여된다. +DELETE FROM user_badges +WHERE badge_id NOT IN (SELECT id FROM badges); diff --git a/src/main/resources/db/migration/V43__user_level_zero_based.sql b/src/main/resources/db/migration/V43__user_level_zero_based.sql new file mode 100644 index 00000000..31780acb --- /dev/null +++ b/src/main/resources/db/migration/V43__user_level_zero_based.sql @@ -0,0 +1,5 @@ +-- 레벨을 0부터 시작하도록 변경: currentLevel = 완료한 미션 수(0~10). +-- 기존 1-base(시작=1) 데이터를 0-base로 한 칸 내리고, 기본값을 0으로 변경한다. +ALTER TABLE user_levels MODIFY current_level INT NOT NULL DEFAULT 0; + +UPDATE user_levels SET current_level = current_level - 1 WHERE current_level > 0; diff --git a/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt new file mode 100644 index 00000000..67517e51 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/integration/scheduler/MissionSchedulerTest.kt @@ -0,0 +1,125 @@ +package com.didit.adapter.integration.scheduler + +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.UserMission +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.LocalDateTime +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class MissionSchedulerTest { + @Mock + lateinit var userMissionRepository: UserMissionRepository + + @Mock + lateinit var missionRepository: MissionRepository + + @Mock + lateinit var retrospectiveRepository: RetrospectiveRepository + + private lateinit var missionScheduler: MissionScheduler + + private val userId = UUID.randomUUID() + private val missionId = UUID.randomUUID() + + @Test + fun `만료된 Lv2 미션을 WAIT_CONFIRM 상태로 변경한다`() { + val expiredMission = + UserMission( + userId = userId, + missionId = missionId, + status = MissionStatus.IN_PROGRESS, + progress = 1, + startedAt = LocalDateTime.now().minusDays(8), + ) + + missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + + whenever(userMissionRepository.findExpiredLv2Missions()).thenReturn(listOf(expiredMission)) + whenever(userMissionRepository.save(any())).thenReturn(expiredMission) + + missionScheduler.checkExpiredLv2Missions() + + verify(userMissionRepository).findExpiredLv2Missions() + verify(userMissionRepository).save(expiredMission) + } + + @Test + fun `만료된 Lv2 미션이 없으면 아무것도 변경하지 않는다`() { + missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + + whenever(userMissionRepository.findExpiredLv2Missions()).thenReturn(emptyList()) + + missionScheduler.checkExpiredLv2Missions() + + verify(userMissionRepository).findExpiredLv2Missions() + } + + @Test + fun `지난주 회고가 없는 연속주차 미션을 WAIT_CONFIRM 상태로 변경한다`() { + val consecutiveWeekMission = + UserMission( + userId = userId, + missionId = missionId, + status = MissionStatus.IN_PROGRESS, + progress = 0, + ) + + missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + + whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn( + listOf(consecutiveWeekMission), + ) + whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(0) + whenever(userMissionRepository.save(any())).thenReturn(consecutiveWeekMission) + + missionScheduler.checkConsecutiveWeekFailure() + + verify(userMissionRepository).findConsecutiveWeekMissionsInProgress() + verify(retrospectiveRepository).countCompletedByUserIdAndPeriod(any(), any(), any()) + verify(userMissionRepository).save(consecutiveWeekMission) + } + + @Test + fun `지난주 회고가 있는 연속주차 미션은 변경하지 않는다`() { + val consecutiveWeekMission = + UserMission( + userId = userId, + missionId = missionId, + status = MissionStatus.IN_PROGRESS, + progress = 0, + ) + + missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + + whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn( + listOf(consecutiveWeekMission), + ) + whenever(retrospectiveRepository.countCompletedByUserIdAndPeriod(any(), any(), any())).thenReturn(1) + + missionScheduler.checkConsecutiveWeekFailure() + + verify(userMissionRepository).findConsecutiveWeekMissionsInProgress() + verify(retrospectiveRepository).countCompletedByUserIdAndPeriod(any(), any(), any()) + } + + @Test + fun `연속주차 진행 미션이 없으면 아무것도 변경하지 않는다`() { + missionScheduler = MissionScheduler(userMissionRepository, missionRepository, retrospectiveRepository) + + whenever(userMissionRepository.findConsecutiveWeekMissionsInProgress()).thenReturn(emptyList()) + + missionScheduler.checkConsecutiveWeekFailure() + + verify(userMissionRepository).findConsecutiveWeekMissionsInProgress() + } +} diff --git a/src/test/kotlin/com/didit/adapter/webapi/achievement/BadgeApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/achievement/BadgeApiTest.kt index 8212ba64..3fb4645d 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/achievement/BadgeApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/achievement/BadgeApiTest.kt @@ -4,6 +4,7 @@ import com.didit.application.achievement.dto.BadgeResponse import com.didit.application.achievement.provided.BadgeFinder import com.didit.docs.ApiDocumentUtils import com.didit.docs.AuthenticatedRestDocsSupport +import com.didit.domain.achievement.BadgeCategory import com.didit.domain.achievement.BadgeConditionType import org.junit.jupiter.api.Test import org.mockito.Mockito.mock @@ -23,25 +24,47 @@ class BadgeApiTest : AuthenticatedRestDocsSupport() { override fun initController() = BadgeApi(badgeFinder) private fun badgeResponse( - conditionType: BadgeConditionType, + conditionType: BadgeConditionType = BadgeConditionType.CUMULATIVE_RETRO, + category: BadgeCategory = BadgeCategory.CONSISTENCY, + threshold: Int = 1, acquired: Boolean = false, acquiredAt: LocalDateTime? = null, ) = BadgeResponse( id = UUID.randomUUID(), name = "첫 기록", description = "첫 회고 저장 완료", + category = category.name, conditionType = conditionType.name, + threshold = threshold, + iconUrl = null, + congratsTitle = null, + congratsMessage = null, acquired = acquired, acquiredAt = acquiredAt, ) + private fun responseFields() = + responseFields( + fieldWithPath("data[].id").type(JsonFieldType.STRING).description("배지 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배지 이름"), + fieldWithPath("data[].description").type(JsonFieldType.STRING).description("배지 설명"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("배지 카테고리"), + fieldWithPath("data[].conditionType").type(JsonFieldType.STRING).description("배지 조건 타입"), + fieldWithPath("data[].threshold").type(JsonFieldType.NUMBER).description("조건 임계값"), + fieldWithPath("data[].iconUrl").type(JsonFieldType.STRING).description("배지 아이콘 URL").optional(), + fieldWithPath("data[].congratsTitle").type(JsonFieldType.STRING).description("축하 타이틀").optional(), + fieldWithPath("data[].congratsMessage").type(JsonFieldType.STRING).description("축하 메시지").optional(), + fieldWithPath("data[].acquired").type(JsonFieldType.BOOLEAN).description("획득 여부"), + fieldWithPath("data[].acquiredAt").type(JsonFieldType.STRING).description("획득 시간").optional(), + ) + @Test fun `배지 목록 조회`() { val badges = listOf( - badgeResponse(BadgeConditionType.FIRST_RETRO, acquired = true, acquiredAt = LocalDateTime.now()), - badgeResponse(BadgeConditionType.STREAK_3_DAYS, acquired = false), - badgeResponse(BadgeConditionType.TOTAL_30, acquired = false), + badgeResponse(BadgeConditionType.CUMULATIVE_RETRO, acquired = true, acquiredAt = LocalDateTime.now()), + badgeResponse(BadgeConditionType.DAILY_ACCESS_STREAK, category = BadgeCategory.ACCESS, threshold = 7), + badgeResponse(BadgeConditionType.PROJECT_COUNT, category = BadgeCategory.PROJECT, threshold = 3), ) whenever(badgeFinder.findAll(userId)).thenReturn(badges) @@ -53,14 +76,34 @@ class BadgeApiTest : AuthenticatedRestDocsSupport() { "badge/find-all", ApiDocumentUtils.getDocumentRequest(), ApiDocumentUtils.getDocumentResponse(), - responseFields( - fieldWithPath("data[].id").type(JsonFieldType.STRING).description("배지 ID"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배지 이름"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("배지 설명"), - fieldWithPath("data[].conditionType").type(JsonFieldType.STRING).description("배지 조건 타입"), - fieldWithPath("data[].acquired").type(JsonFieldType.BOOLEAN).description("획득 여부"), - fieldWithPath("data[].acquiredAt").type(JsonFieldType.STRING).description("획득 시간").optional(), - ), + responseFields(), + ), + ) + } + + @Test + fun `최근 획득 배지 조회`() { + val badges = + listOf( + badgeResponse( + BadgeConditionType.DAILY_ACCESS_STREAK, + category = BadgeCategory.ACCESS, + threshold = 7, + acquired = true, + acquiredAt = LocalDateTime.now(), + ), + ) + whenever(badgeFinder.findRecent(userId)).thenReturn(badges) + + mockMvc + .perform(get("/api/v1/badges/recent")) + .andExpect(status().isOk) + .andDo( + document( + "badge/find-recent", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields(), ), ) } @@ -69,8 +112,14 @@ class BadgeApiTest : AuthenticatedRestDocsSupport() { fun `미알림 배지 조회`() { val badges = listOf( - badgeResponse(BadgeConditionType.FIRST_RETRO, acquired = true, acquiredAt = LocalDateTime.now()), - badgeResponse(BadgeConditionType.STREAK_3_DAYS, acquired = true, acquiredAt = LocalDateTime.now()), + badgeResponse(BadgeConditionType.CUMULATIVE_RETRO, acquired = true, acquiredAt = LocalDateTime.now()), + badgeResponse( + BadgeConditionType.DAILY_ACCESS_STREAK, + category = BadgeCategory.ACCESS, + threshold = 7, + acquired = true, + acquiredAt = LocalDateTime.now(), + ), ) whenever(badgeFinder.findUnnotified(userId)).thenReturn(badges) @@ -82,14 +131,7 @@ class BadgeApiTest : AuthenticatedRestDocsSupport() { "badge/popup", ApiDocumentUtils.getDocumentRequest(), ApiDocumentUtils.getDocumentResponse(), - responseFields( - fieldWithPath("data[].id").type(JsonFieldType.STRING).description("배지 ID"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배지 이름"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("배지 설명"), - fieldWithPath("data[].conditionType").type(JsonFieldType.STRING).description("배지 조건 타입"), - fieldWithPath("data[].acquired").type(JsonFieldType.BOOLEAN).description("획득 여부"), - fieldWithPath("data[].acquiredAt").type(JsonFieldType.STRING).description("획득 시간").optional(), - ), + responseFields(), ), ) } diff --git a/src/test/kotlin/com/didit/adapter/webapi/achievement/MissionApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/achievement/MissionApiTest.kt new file mode 100644 index 00000000..33271cfe --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/webapi/achievement/MissionApiTest.kt @@ -0,0 +1,130 @@ +package com.didit.adapter.webapi.achievement + +import com.didit.application.achievement.dto.CurrentMissionResponse +import com.didit.application.achievement.dto.MissionInfo +import com.didit.application.achievement.dto.PopupStatus +import com.didit.application.achievement.dto.WeeklyStatus +import com.didit.application.achievement.provided.MissionFinder +import com.didit.application.achievement.provided.UserMissionRegister +import com.didit.docs.ApiDocumentUtils +import com.didit.docs.AuthenticatedRestDocsSupport +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDate + +class MissionApiTest : AuthenticatedRestDocsSupport() { + private val missionFinder: MissionFinder = mock(MissionFinder::class.java) + private val userMissionRegister: UserMissionRegister = mock(UserMissionRegister::class.java) + + override fun initController() = MissionApi(missionFinder, userMissionRegister) + + @Test + fun `현재 미션을 조회한다`() { + val currentMissionResponse = + CurrentMissionResponse( + currentLevel = 3, + mission = + MissionInfo( + type = "CONSECUTIVE_WEEK", + title = "2주 연속 회고 작성하기", + description = "매주 한 번씩 회고를 작성하면 달성할 수 있어요", + progress = 1, + target = 2, + remainingDays = null, + cta = "회고 남기기", + ), + weeklyStatus = + WeeklyStatus( + show = true, + weekStart = LocalDate.now(), + days = listOf(true, false, false, false, false, false, false), + ), + popup = PopupStatus(exists = true, type = "LEVEL_UP"), + ) + + whenever(missionFinder.getCurrentMission(userId)).thenReturn(currentMissionResponse) + + mockMvc + .perform(get("/api/v1/missions/current")) + .andExpect(status().isOk) + .andDo( + document( + "mission/current", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields( + fieldWithPath("data.currentLevel").type(JsonFieldType.NUMBER).description("현재 레벨"), + fieldWithPath("data.mission.type").type(JsonFieldType.STRING).description("미션 타입"), + fieldWithPath("data.mission.title").type(JsonFieldType.STRING).description("미션 제목"), + fieldWithPath("data.mission.description").type(JsonFieldType.STRING).description("미션 설명"), + fieldWithPath("data.mission.progress").type(JsonFieldType.NUMBER).description("현재 진행도"), + fieldWithPath("data.mission.target").type(JsonFieldType.NUMBER).description("목표값"), + fieldWithPath("data.mission.remainingDays") + .type(JsonFieldType.NUMBER) + .description("남은 일수") + .optional(), + fieldWithPath("data.mission.cta").type(JsonFieldType.STRING).description("CTA 텍스트"), + fieldWithPath("data.weeklyStatus") + .type(JsonFieldType.OBJECT) + .description("주간 회고 현황") + .optional(), + fieldWithPath("data.weeklyStatus.show") + .type(JsonFieldType.BOOLEAN) + .description("주간 상황 노출 여부") + .optional(), + fieldWithPath("data.weeklyStatus.weekStart") + .type(JsonFieldType.STRING) + .description("주간 시작일") + .optional(), + fieldWithPath("data.weeklyStatus.days") + .type(JsonFieldType.ARRAY) + .description("요일별 완료 현황") + .optional(), + fieldWithPath("data.popup.exists").type(JsonFieldType.BOOLEAN).description("팝업 노출 여부"), + fieldWithPath("data.popup.type").type(JsonFieldType.STRING).description("팝업 타입").optional(), + ), + ), + ) + } + + @Test + fun `레벨업 팝업 확인을 처리한다`() { + mockMvc + .perform(post("/api/v1/missions/level-up/confirm")) + .andExpect(status().isNoContent) + .andDo( + document( + "mission/level-up-confirm", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + ), + ) + + verify(userMissionRegister).confirmLevelUp(userId) + } + + @Test + fun `재도전을 시작한다`() { + mockMvc + .perform(post("/api/v1/missions/retry")) + .andExpect(status().isNoContent) + .andDo( + document( + "mission/retry", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + ), + ) + + verify(userMissionRegister).retryMission(userId) + } +} diff --git a/src/test/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApiTest.kt new file mode 100644 index 00000000..86365ac6 --- /dev/null +++ b/src/test/kotlin/com/didit/adapter/webapi/admin/AdminAchievementStatsApiTest.kt @@ -0,0 +1,118 @@ +package com.didit.adapter.webapi.admin + +import com.didit.application.admin.provided.AdminAchievementStatsFinder +import com.didit.application.admin.provided.AdminBadgeStat +import com.didit.application.admin.provided.AdminLevelStat +import com.didit.application.admin.provided.AdminMissionStat +import com.didit.docs.AdminAuthenticatedRestDocsSupport +import com.didit.docs.ApiDocumentUtils +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.UUID + +class AdminAchievementStatsApiTest : AdminAuthenticatedRestDocsSupport() { + private val adminAchievementStatsFinder: AdminAchievementStatsFinder = + mock(AdminAchievementStatsFinder::class.java) + + override fun initController() = AdminAchievementStatsApi(adminAchievementStatsFinder) + + @Test + fun `레벨 분포 통계 조회`() { + whenever(adminAchievementStatsFinder.getLevelStats()).thenReturn( + listOf(AdminLevelStat(level = 1, userCount = 120)), + ) + + mockMvc + .perform(get("/api/v1/admin/stats/levels").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andDo( + document( + "admin/stats/levels", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields( + fieldWithPath("data[].level").type(JsonFieldType.NUMBER).description("레벨"), + fieldWithPath("data[].userCount").type(JsonFieldType.NUMBER).description("해당 레벨 유저 수"), + ), + ), + ) + } + + @Test + fun `미션 달성 통계 조회`() { + whenever(adminAchievementStatsFinder.getMissionStats()).thenReturn( + listOf( + AdminMissionStat( + level = 2, + inProgress = 3, + completed = 5, + failed = 2, + total = 10, + completionRate = 50.0, + ), + ), + ) + + mockMvc + .perform(get("/api/v1/admin/stats/missions").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andDo( + document( + "admin/stats/missions", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields( + fieldWithPath("data[].level").type(JsonFieldType.NUMBER).description("레벨"), + fieldWithPath("data[].inProgress").type(JsonFieldType.NUMBER).description("진행 중 수"), + fieldWithPath("data[].completed").type(JsonFieldType.NUMBER).description("완료 수"), + fieldWithPath("data[].failed").type(JsonFieldType.NUMBER).description("실패 수(미확인 포함)"), + fieldWithPath("data[].total").type(JsonFieldType.NUMBER).description("전체 수"), + fieldWithPath("data[].completionRate").type(JsonFieldType.NUMBER).description("완료율(%)"), + ), + ), + ) + } + + @Test + fun `배지 획득 통계 조회`() { + whenever(adminAchievementStatsFinder.getBadgeStats()).thenReturn( + listOf( + AdminBadgeStat( + badgeId = UUID.randomUUID(), + name = "첫 기록", + category = "CONSISTENCY", + conditionType = "CUMULATIVE_RETRO", + active = true, + acquiredCount = 150, + ), + ), + ) + + mockMvc + .perform(get("/api/v1/admin/stats/badges").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andDo( + document( + "admin/stats/badges", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + responseFields( + fieldWithPath("data[].badgeId").type(JsonFieldType.STRING).description("배지 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배지명"), + fieldWithPath("data[].category").type(JsonFieldType.STRING).description("카테고리"), + fieldWithPath("data[].conditionType").type(JsonFieldType.STRING).description("획득 조건 유형"), + fieldWithPath("data[].active").type(JsonFieldType.BOOLEAN).description("활성 여부"), + fieldWithPath("data[].acquiredCount").type(JsonFieldType.NUMBER).description("획득 유저 수"), + ), + ), + ) + } +} diff --git a/src/test/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApiTest.kt index caed1d73..fa01a6ea 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/admin/AdminBadgeApiTest.kt @@ -1,19 +1,30 @@ package com.didit.adapter.webapi.admin +import com.didit.application.admin.provided.AdminBadgeCategoryItem +import com.didit.application.admin.provided.AdminBadgeConditionTypeItem import com.didit.application.admin.provided.AdminBadgeFinder import com.didit.application.admin.provided.AdminBadgeHolder +import com.didit.application.admin.provided.AdminBadgeMetaResult +import com.didit.application.admin.provided.AdminBadgeParamSpec +import com.didit.application.admin.provided.AdminBadgeRegister import com.didit.application.admin.provided.AdminBadgeResult +import com.didit.application.audit.AuditLogger import com.didit.docs.AdminAuthenticatedRestDocsSupport import com.didit.docs.ApiDocumentUtils import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.springframework.http.MediaType import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put import org.springframework.restdocs.payload.JsonFieldType import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import org.springframework.restdocs.payload.PayloadDocumentation.requestFields import org.springframework.restdocs.payload.PayloadDocumentation.responseFields import org.springframework.restdocs.request.RequestDocumentation.parameterWithName import org.springframework.restdocs.request.RequestDocumentation.pathParameters @@ -23,23 +34,50 @@ import java.util.UUID class AdminBadgeApiTest : AdminAuthenticatedRestDocsSupport() { private val adminBadgeFinder: AdminBadgeFinder = mock(AdminBadgeFinder::class.java) + private val adminBadgeRegister: AdminBadgeRegister = mock(AdminBadgeRegister::class.java) + private val auditLogger: AuditLogger = mock(AuditLogger::class.java) - override fun initController() = AdminBadgeApi(adminBadgeFinder) + override fun initController() = AdminBadgeApi(adminBadgeFinder, adminBadgeRegister, auditLogger) + + private fun badgeResult( + id: UUID = UUID.randomUUID(), + active: Boolean = true, + ) = AdminBadgeResult( + id = id, + name = "첫 기록", + description = "첫 회고를 작성했어요", + category = "CONSISTENCY", + conditionType = "CUMULATIVE_RETRO", + threshold = 1, + params = null, + iconUrl = "https://cdn.didit.ai.kr/badges/first.png", + congratsTitle = "첫 기록 달성!", + congratsMessage = "회고의 첫 걸음을 내디뎠어요", + active = active, + acquiredCount = 150, + createdAt = LocalDateTime.of(2026, 1, 1, 0, 0), + ) + + private fun badgeResultFields(prefix: String) = + listOf( + fieldWithPath("$prefix.id").type(JsonFieldType.STRING).description("배지 ID"), + fieldWithPath("$prefix.name").type(JsonFieldType.STRING).description("배지명"), + fieldWithPath("$prefix.description").type(JsonFieldType.STRING).description("배지 설명"), + fieldWithPath("$prefix.category").type(JsonFieldType.STRING).description("카테고리"), + fieldWithPath("$prefix.conditionType").type(JsonFieldType.STRING).description("획득 조건 유형"), + fieldWithPath("$prefix.threshold").type(JsonFieldType.NUMBER).description("획득 임계값"), + fieldWithPath("$prefix.params").type(JsonFieldType.OBJECT).optional().description("조건 파라미터(JSON)"), + fieldWithPath("$prefix.iconUrl").type(JsonFieldType.STRING).optional().description("아이콘 URL"), + fieldWithPath("$prefix.congratsTitle").type(JsonFieldType.STRING).optional().description("획득 축하 타이틀"), + fieldWithPath("$prefix.congratsMessage").type(JsonFieldType.STRING).optional().description("획득 축하 메시지"), + fieldWithPath("$prefix.active").type(JsonFieldType.BOOLEAN).description("활성 여부"), + fieldWithPath("$prefix.acquiredCount").type(JsonFieldType.NUMBER).description("획득 유저 수"), + fieldWithPath("$prefix.createdAt").type(JsonFieldType.STRING).optional().description("생성 일시"), + ) @Test fun `배지 목록 조회`() { - val result = - listOf( - AdminBadgeResult( - id = UUID.randomUUID(), - name = "첫 회고", - description = "첫 번째 회고를 완료했어요", - conditionType = "RETROSPECT_COUNT", - acquiredCount = 150, - createdAt = LocalDateTime.of(2026, 1, 1, 0, 0), - ), - ) - whenever(adminBadgeFinder.findAll()).thenReturn(result) + whenever(adminBadgeFinder.findAll()).thenReturn(listOf(badgeResult())) mockMvc .perform(get("/api/v1/admin/badges").accept(MediaType.APPLICATION_JSON)) @@ -49,13 +87,78 @@ class AdminBadgeApiTest : AdminAuthenticatedRestDocsSupport() { "admin/badges/list", ApiDocumentUtils.getDocumentRequest(), ApiDocumentUtils.getDocumentResponse(), + responseFields(badgeResultFields("data[]")), + ), + ) + } + + @Test + fun `배지 상세 조회`() { + val badgeId = UUID.randomUUID() + whenever(adminBadgeFinder.findById(any())).thenReturn(badgeResult(id = badgeId)) + + mockMvc + .perform(get("/api/v1/admin/badges/{badgeId}", badgeId).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andDo( + document( + "admin/badges/detail", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + pathParameters(parameterWithName("badgeId").description("배지 ID")), + responseFields(badgeResultFields("data")), + ), + ) + } + + @Test + fun `배지 조건 타입 메타 조회`() { + val meta = + AdminBadgeMetaResult( + conditionTypes = + listOf( + AdminBadgeConditionTypeItem( + conditionType = "WEEKLY_STREAK", + label = "연속 주차", + description = "매주 weeklyMinCount회 이상을 threshold주 연속 달성하면 부여", + params = + listOf( + AdminBadgeParamSpec( + key = "weeklyMinCount", + label = "주당 최소 회고 수", + type = "number", + defaultValue = 1, + required = false, + ), + ), + ), + ), + categories = listOf(AdminBadgeCategoryItem(category = "CONSISTENCY", label = "꾸준함")), + ) + whenever(adminBadgeFinder.findMeta()).thenReturn(meta) + + mockMvc + .perform(get("/api/v1/admin/badges/condition-types").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + .andDo( + document( + "admin/badges/condition-types", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), responseFields( - fieldWithPath("data[].id").type(JsonFieldType.STRING).description("배지 ID"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배지명"), - fieldWithPath("data[].description").type(JsonFieldType.STRING).description("배지 설명"), - fieldWithPath("data[].conditionType").type(JsonFieldType.STRING).description("획득 조건 유형"), - fieldWithPath("data[].acquiredCount").type(JsonFieldType.NUMBER).description("획득 유저 수"), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).optional().description("생성 일시"), + fieldWithPath("data.conditionTypes[].conditionType").type(JsonFieldType.STRING).description("조건 타입"), + fieldWithPath("data.conditionTypes[].label").type(JsonFieldType.STRING).description("조건 타입 라벨"), + fieldWithPath("data.conditionTypes[].description").type(JsonFieldType.STRING).description("설명"), + fieldWithPath("data.conditionTypes[].params[].key").type(JsonFieldType.STRING).description("파라미터 키"), + fieldWithPath("data.conditionTypes[].params[].label").type(JsonFieldType.STRING).description("파라미터 라벨"), + fieldWithPath("data.conditionTypes[].params[].type").type(JsonFieldType.STRING).description("파라미터 타입"), + fieldWithPath("data.conditionTypes[].params[].defaultValue") + .type(JsonFieldType.NUMBER) + .optional() + .description("기본값"), + fieldWithPath("data.conditionTypes[].params[].required").type(JsonFieldType.BOOLEAN).description("필수 여부"), + fieldWithPath("data.categories[].category").type(JsonFieldType.STRING).description("카테고리"), + fieldWithPath("data.categories[].label").type(JsonFieldType.STRING).description("카테고리 라벨"), ), ), ) @@ -97,4 +200,121 @@ class AdminBadgeApiTest : AdminAuthenticatedRestDocsSupport() { ), ) } + + @Test + fun `배지 생성`() { + whenever(adminBadgeRegister.create(any())).thenReturn(badgeResult()) + + val body = + """ + { + "name": "첫 기록", + "description": "첫 회고를 작성했어요", + "category": "CONSISTENCY", + "conditionType": "CUMULATIVE_RETRO", + "threshold": 1, + "iconUrl": "https://cdn.didit.ai.kr/badges/first.png", + "congratsTitle": "첫 기록 달성!", + "congratsMessage": "회고의 첫 걸음을 내디뎠어요" + } + """.trimIndent() + + mockMvc + .perform( + post("/api/v1/admin/badges") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andDo( + document( + "admin/badges/create", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배지명"), + fieldWithPath("description").type(JsonFieldType.STRING).description("배지 설명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("카테고리"), + fieldWithPath("conditionType").type(JsonFieldType.STRING).description("획득 조건 유형"), + fieldWithPath("threshold").type(JsonFieldType.NUMBER).description("획득 임계값(1 이상)"), + fieldWithPath("params").type(JsonFieldType.OBJECT).optional().description("조건 파라미터(JSON)"), + fieldWithPath("iconUrl").type(JsonFieldType.STRING).optional().description("아이콘 URL"), + fieldWithPath("congratsTitle").type(JsonFieldType.STRING).optional().description("획득 축하 타이틀"), + fieldWithPath("congratsMessage").type(JsonFieldType.STRING).optional().description("획득 축하 메시지"), + ), + responseFields(badgeResultFields("data")), + ), + ) + } + + @Test + fun `배지 수정`() { + val badgeId = UUID.randomUUID() + whenever(adminBadgeRegister.update(any(), any())).thenReturn(badgeResult(id = badgeId)) + + val body = + """ + { + "name": "첫 기록", + "description": "수정된 설명", + "category": "CONSISTENCY", + "conditionType": "CUMULATIVE_RETRO", + "threshold": 1 + } + """.trimIndent() + + mockMvc + .perform( + put("/api/v1/admin/badges/{badgeId}", badgeId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andDo( + document( + "admin/badges/update", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + pathParameters(parameterWithName("badgeId").description("배지 ID")), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배지명"), + fieldWithPath("description").type(JsonFieldType.STRING).description("배지 설명"), + fieldWithPath("category").type(JsonFieldType.STRING).description("카테고리"), + fieldWithPath("conditionType").type(JsonFieldType.STRING).description("획득 조건 유형"), + fieldWithPath("threshold").type(JsonFieldType.NUMBER).description("획득 임계값(1 이상)"), + fieldWithPath("params").type(JsonFieldType.OBJECT).optional().description("조건 파라미터(JSON)"), + fieldWithPath("iconUrl").type(JsonFieldType.STRING).optional().description("아이콘 URL"), + fieldWithPath("congratsTitle").type(JsonFieldType.STRING).optional().description("획득 축하 타이틀"), + fieldWithPath("congratsMessage").type(JsonFieldType.STRING).optional().description("획득 축하 메시지"), + ), + responseFields(badgeResultFields("data")), + ), + ) + } + + @Test + fun `배지 활성 상태 변경`() { + val badgeId = UUID.randomUUID() + whenever(adminBadgeRegister.changeActive(any(), eq(false))).thenReturn(badgeResult(id = badgeId, active = false)) + + mockMvc + .perform( + patch("/api/v1/admin/badges/{badgeId}/active", badgeId) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"active": false}""") + .accept(MediaType.APPLICATION_JSON), + ).andExpect(status().isOk) + .andDo( + document( + "admin/badges/active", + ApiDocumentUtils.getDocumentRequest(), + ApiDocumentUtils.getDocumentResponse(), + pathParameters(parameterWithName("badgeId").description("배지 ID")), + requestFields( + fieldWithPath("active").type(JsonFieldType.BOOLEAN).description("활성 여부"), + ), + responseFields(badgeResultFields("data")), + ), + ) + } } diff --git a/src/test/kotlin/com/didit/adapter/webapi/auth/UserApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/auth/UserApiTest.kt index 65a9fb29..c6ad0032 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/auth/UserApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/auth/UserApiTest.kt @@ -1,6 +1,7 @@ package com.didit.adapter.webapi.auth import com.didit.application.achievement.provided.BadgeFinder +import com.didit.application.achievement.provided.UserLevelFinder import com.didit.application.auth.provided.UserFinder import com.didit.application.auth.provided.UserRegister import com.didit.docs.ApiDocumentUtils @@ -29,8 +30,9 @@ class UserApiTest : AuthenticatedRestDocsSupport() { private val userFinder: UserFinder = mock(UserFinder::class.java) private val userRegister: UserRegister = mock(UserRegister::class.java) private val badgeFinder: BadgeFinder = mock(BadgeFinder::class.java) + private val userLevelFinder: UserLevelFinder = mock(UserLevelFinder::class.java) - override fun initController() = UserApi(userFinder, userRegister, badgeFinder) + override fun initController() = UserApi(userFinder, userRegister, badgeFinder, userLevelFinder) @Test fun `닉네임 중복 확인`() { @@ -190,6 +192,7 @@ class UserApiTest : AuthenticatedRestDocsSupport() { val user = UserFixture.createOnboarded() whenever(userFinder.findByIdOrThrow(userId)).thenReturn(user) whenever(badgeFinder.findRecent(userId)).thenReturn(emptyList()) + whenever(userLevelFinder.getCurrentLevel(userId)).thenReturn(5) mockMvc .perform(get("/api/v2/users/profile")) @@ -201,6 +204,7 @@ class UserApiTest : AuthenticatedRestDocsSupport() { ApiDocumentUtils.getDocumentResponse(), responseFields( fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("data.currentLevel").type(JsonFieldType.NUMBER).description("현재 레벨 (1~10)"), fieldWithPath("data.job") .type(JsonFieldType.STRING) .description("직무 (DEVELOPER, PLANNER, DESIGNER)"), diff --git a/src/test/kotlin/com/didit/adapter/webapi/home/HomeApiTest.kt b/src/test/kotlin/com/didit/adapter/webapi/home/HomeApiTest.kt index 75ad786a..5eab753e 100644 --- a/src/test/kotlin/com/didit/adapter/webapi/home/HomeApiTest.kt +++ b/src/test/kotlin/com/didit/adapter/webapi/home/HomeApiTest.kt @@ -1,6 +1,12 @@ package com.didit.adapter.webapi.home +import com.didit.application.achievement.dto.CurrentMissionResponse +import com.didit.application.achievement.dto.MissionInfo +import com.didit.application.achievement.dto.PopupStatus +import com.didit.application.achievement.provided.MissionFinder +import com.didit.application.achievement.provided.UserMissionRegister import com.didit.application.auth.provided.UserFinder +import com.didit.application.notification.provided.NotificationHistoryFinder import com.didit.application.retrospect.dto.RetrospectiveDetailResult import com.didit.application.retrospect.provided.RetrospectiveFinder import com.didit.docs.ApiDocumentUtils @@ -18,14 +24,35 @@ import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get import org.springframework.restdocs.payload.JsonFieldType import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import java.util.UUID class HomeApiTest : AuthenticatedRestDocsSupport() { private val userFinder: UserFinder = mock(UserFinder::class.java) private val retrospectiveFinder: RetrospectiveFinder = mock(RetrospectiveFinder::class.java) + private val notificationHistoryFinder: NotificationHistoryFinder = mock(NotificationHistoryFinder::class.java) + private val missionFinder: MissionFinder = mock(MissionFinder::class.java) + private val userMissionRegister: UserMissionRegister = mock(UserMissionRegister::class.java) - override fun initController() = HomeApi(userFinder, retrospectiveFinder) + override fun initController() = HomeApi(userFinder, retrospectiveFinder, notificationHistoryFinder, missionFinder, userMissionRegister) + + private fun currentMission() = + CurrentMissionResponse( + currentLevel = 0, + mission = + MissionInfo( + type = "FIRST_RETRO", + title = "첫 회고 작성하기", + description = "첫 회고를 작성해보세요", + progress = 0, + target = 1, + remainingDays = null, + cta = "회고 시작하기", + ), + weeklyStatus = null, + popup = PopupStatus(exists = false, type = null), + ) @Test fun `홈 조회`() { @@ -71,6 +98,8 @@ class HomeApiTest : AuthenticatedRestDocsSupport() { whenever(userFinder.findByIdOrThrow(userId)).thenReturn(user) whenever(retrospectiveFinder.findRecentWithProjectAndTagsByUserId(userId, 5)).thenReturn(listOf(result)) whenever(retrospectiveFinder.countByUserIdAndDate(userId, ServiceTime.today())).thenReturn(1) + whenever(notificationHistoryFinder.hasUnread(userId)).thenReturn(true) + whenever(missionFinder.getCurrentMission(userId)).thenReturn(currentMission()) mockMvc .perform(get("/api/v2/home")) @@ -83,6 +112,8 @@ class HomeApiTest : AuthenticatedRestDocsSupport() { responseFields( fieldWithPath("data.nickname").type(JsonFieldType.STRING).description("닉네임"), fieldWithPath("data.todayRetrospectiveCount").type(JsonFieldType.NUMBER).description("오늘 회고 횟수"), + fieldWithPath("data.hasUnreadNotification").type(JsonFieldType.BOOLEAN).description("안 읽은 알림 존재 여부"), + subsectionWithPath("data.mission").description("현재 미션/레벨 정보 (GET /api/v1/missions/current와 동일 구조)"), fieldWithPath("data.recentRetrospectives").type(JsonFieldType.ARRAY).description("최근 회고 목록"), fieldWithPath("data.recentRetrospectives[].id").type(JsonFieldType.STRING).description("회고 ID"), fieldWithPath("data.recentRetrospectives[].title").type(JsonFieldType.STRING).description("회고 제목").optional(), diff --git a/src/test/kotlin/com/didit/application/achievement/BadgeAwarderServiceTest.kt b/src/test/kotlin/com/didit/application/achievement/BadgeAwarderServiceTest.kt index 7ca431ce..1d7bb02b 100644 --- a/src/test/kotlin/com/didit/application/achievement/BadgeAwarderServiceTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/BadgeAwarderServiceTest.kt @@ -1,14 +1,18 @@ package com.didit.application.achievement +import com.didit.application.achievement.provided.DailyAccessTracker import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.DailyAccessStreakRepository +import com.didit.application.achievement.required.OrganizationAchievementReader import com.didit.application.achievement.required.RetrospectAchievementReader -import com.didit.application.achievement.required.StreakRepository import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.achievement.required.WeeklyRetroStreakRepository import com.didit.application.audit.AuditLogger import com.didit.domain.achievement.Badge import com.didit.domain.achievement.BadgeConditionType -import com.didit.domain.achievement.Streak +import com.didit.domain.achievement.DailyAccessStreak import com.didit.domain.achievement.UserBadge +import com.didit.support.BadgeFixture import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -28,69 +32,75 @@ class BadgeAwarderServiceTest { @Mock lateinit var userBadgeRepository: UserBadgeRepository - @Mock lateinit var streakRepository: StreakRepository + @Mock lateinit var weeklyRetroStreakRepository: WeeklyRetroStreakRepository + + @Mock lateinit var dailyAccessStreakRepository: DailyAccessStreakRepository + + @Mock lateinit var dailyAccessTracker: DailyAccessTracker @Mock lateinit var retrospectAchievementReader: RetrospectAchievementReader + @Mock lateinit var organizationAchievementReader: OrganizationAchievementReader + @Mock lateinit var auditLogger: AuditLogger private lateinit var badgeService: BadgeAwarderService - private lateinit var badgeConditionChecker: BadgeConditionChecker private val userId = UUID.randomUUID() - private val today = LocalDate.now() + private val today = LocalDate.of(2026, 6, 24) // 수요일 @BeforeEach fun setUp() { - badgeConditionChecker = BadgeConditionChecker() badgeService = BadgeAwarderService( badgeRepository = badgeRepository, userBadgeRepository = userBadgeRepository, - streakRepository = streakRepository, + weeklyRetroStreakRepository = weeklyRetroStreakRepository, + dailyAccessStreakRepository = dailyAccessStreakRepository, + dailyAccessTracker = dailyAccessTracker, retrospectAchievementReader = retrospectAchievementReader, - badgeConditionChecker = badgeConditionChecker, + organizationAchievementReader = organizationAchievementReader, + badgeConditionChecker = BadgeConditionChecker(), auditLogger = auditLogger, ) } - private fun badge(conditionType: BadgeConditionType) = - Badge.create( - name = conditionType.name, - description = "설명", - conditionType = conditionType, - ) - private fun defaultMocks( totalRetroCount: Int = 1, - deepQuestionCount: Int = 0, - weeklyGoalAchievedWeeks: Int = 0, - existingStreak: Streak? = null, + currentWeekRetroCount: Int = 1, + weeklyStreakWithMin3: Int = 0, + projectCount: Int = 0, + projectAssignedRetroCount: Int = 0, + maxRetroInOneProject: Int = 0, ) { - whenever(streakRepository.findByUserId(userId)).thenReturn(existingStreak) - whenever(streakRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(weeklyRetroStreakRepository.findByUserId(userId)).thenReturn(null) + whenever(weeklyRetroStreakRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(dailyAccessStreakRepository.findByUserId(userId)).thenReturn(null) whenever(retrospectAchievementReader.countCompletedRetros(userId)).thenReturn(totalRetroCount) - whenever(retrospectAchievementReader.countDeepQuestionAnswers(userId)).thenReturn(deepQuestionCount) - whenever(retrospectAchievementReader.countWeeklyGoalAchievedWeeks(userId)).thenReturn(weeklyGoalAchievedWeeks) + whenever(retrospectAchievementReader.countRetrosInWeek(eq(userId), any())).thenReturn(currentWeekRetroCount) + whenever(retrospectAchievementReader.countConsecutiveWeeksWithMinRetros(userId, 3)).thenReturn(weeklyStreakWithMin3) + whenever(organizationAchievementReader.countProjects(userId)).thenReturn(projectCount) + whenever(organizationAchievementReader.countProjectAssignedRetros(userId)).thenReturn(projectAssignedRetroCount) + whenever(organizationAchievementReader.maxRetroCountInOneProject(userId)).thenReturn(maxRetroInOneProject) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) } @Test - fun `awardBadges - 첫 회고 시 FIRST_RETRO 배지를 부여한다`() { + fun `awardBadges - 첫 회고 시 CUMULATIVE_RETRO 1 배지를 부여한다`() { defaultMocks(totalRetroCount = 1) - whenever(badgeRepository.findAll()).thenReturn(listOf(badge(BadgeConditionType.FIRST_RETRO))) + whenever(badgeRepository.findAll()).thenReturn(listOf(BadgeFixture.cumulativeRetro(1))) whenever(userBadgeRepository.save(any())).thenAnswer { it.arguments[0] } val result = badgeService.awardBadges(userId, today) assertThat(result).hasSize(1) - assertThat(result[0].conditionType).isEqualTo(BadgeConditionType.FIRST_RETRO) + assertThat(result[0].conditionType).isEqualTo(BadgeConditionType.CUMULATIVE_RETRO) verify(userBadgeRepository).save(any()) } @Test fun `awardBadges - 이미 획득한 배지는 다시 부여하지 않는다`() { - val existingBadge = badge(BadgeConditionType.FIRST_RETRO) + val existingBadge = BadgeFixture.cumulativeRetro(1) defaultMocks(totalRetroCount = 1) whenever(badgeRepository.findAll()).thenReturn(listOf(existingBadge)) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn( @@ -104,26 +114,33 @@ class BadgeAwarderServiceTest { } @Test - fun `awardBadges - 3일 연속 회고 시 STREAK_3_DAYS 배지를 부여한다`() { - val streak = - Streak.create(userId).apply { - update(today.minusDays(2)) - update(today.minusDays(1)) - } - defaultMocks(existingStreak = streak) - whenever(badgeRepository.findAll()).thenReturn(listOf(badge(BadgeConditionType.STREAK_3_DAYS))) + fun `awardBadges - 비활성 배지는 후보에서 제외한다`() { + val activeBadge = BadgeFixture.cumulativeRetro(1) + val inactiveBadge: Badge = BadgeFixture.cumulativeRetro(1).apply { active = false } + defaultMocks(totalRetroCount = 1) + whenever(badgeRepository.findAll()).thenReturn(listOf(activeBadge, inactiveBadge)) whenever(userBadgeRepository.save(any())).thenAnswer { it.arguments[0] } val result = badgeService.awardBadges(userId, today) assertThat(result).hasSize(1) - assertThat(result[0].conditionType).isEqualTo(BadgeConditionType.STREAK_3_DAYS) + assertThat(result.first().id).isEqualTo(activeBadge.id) } @Test - fun `awardBadges - 조건 미충족 시 배지를 부여하지 않는다`() { + fun `awardBadges - 주간 회고 스트릭을 갱신한다`() { defaultMocks(totalRetroCount = 1) - whenever(badgeRepository.findAll()).thenReturn(listOf(badge(BadgeConditionType.TOTAL_30))) + whenever(badgeRepository.findAll()).thenReturn(emptyList()) + + badgeService.awardBadges(userId, today) + + verify(weeklyRetroStreakRepository).save(any()) + } + + @Test + fun `awardBadges - PROJECT_COUNT 조건 미충족 시 부여하지 않는다`() { + defaultMocks(projectCount = 2) + whenever(badgeRepository.findAll()).thenReturn(listOf(BadgeFixture.projectCount(3))) val result = badgeService.awardBadges(userId, today) @@ -132,17 +149,71 @@ class BadgeAwarderServiceTest { } @Test - fun `awardBadges - 스트릭이 없으면 새로 생성한다`() { - whenever(streakRepository.findByUserId(userId)).thenReturn(null) - whenever(streakRepository.save(any())).thenAnswer { it.arguments[0] } - whenever(retrospectAchievementReader.countCompletedRetros(userId)).thenReturn(1) - whenever(retrospectAchievementReader.countDeepQuestionAnswers(userId)).thenReturn(0) - whenever(retrospectAchievementReader.countWeeklyGoalAchievedWeeks(userId)).thenReturn(0) + fun `awardBadges - WEEKLY_STREAK weeklyMin 3 조건은 weeklyStreakWithMin3 값으로 판정된다`() { + defaultMocks(weeklyStreakWithMin3 = 3) + whenever(badgeRepository.findAll()).thenReturn(listOf(BadgeFixture.weeklyStreak(threshold = 3, weeklyMin = 3))) + whenever(userBadgeRepository.save(any())).thenAnswer { it.arguments[0] } + + val result = badgeService.awardBadges(userId, today) + + assertThat(result).hasSize(1) + } + + @Test + fun `awardAccessBadges - 7일 연속 접속 시 디딧 러버 배지를 부여한다`() { + val streak = + DailyAccessStreak.create(userId).apply { + repeat(7) { i -> recordAccess(today.minusDays((6 - i).toLong())) } + } + whenever(dailyAccessTracker.recordAccess(userId, today)).thenReturn(streak) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) - whenever(badgeRepository.findAll()).thenReturn(emptyList()) + whenever(badgeRepository.findAll()).thenReturn(listOf(BadgeFixture.dailyAccessStreak(7))) + whenever(userBadgeRepository.save(any())).thenAnswer { it.arguments[0] } - badgeService.awardBadges(userId, today) + val result = badgeService.awardAccessBadges(userId, today) + + assertThat(result).hasSize(1) + assertThat(result[0].conditionType).isEqualTo(BadgeConditionType.DAILY_ACCESS_STREAK) + verify(userBadgeRepository).save(any()) + } + + @Test + fun `awardAccessBadges - 스트릭이 부족하면 부여하지 않는다`() { + val streak = + DailyAccessStreak.create(userId).apply { + recordAccess(today) + } + whenever(dailyAccessTracker.recordAccess(userId, today)).thenReturn(streak) + whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) + whenever(badgeRepository.findAll()).thenReturn(listOf(BadgeFixture.dailyAccessStreak(7))) + + val result = badgeService.awardAccessBadges(userId, today) + + assertThat(result).isEmpty() + verify(userBadgeRepository, never()).save(any()) + } - verify(streakRepository).save(any()) + @Test + fun `awardAccessBadges - 접속 외 조건 배지는 평가하지 않는다`() { + val streak = + DailyAccessStreak.create(userId).apply { + repeat(7) { i -> recordAccess(today.minusDays((6 - i).toLong())) } + } + whenever(dailyAccessTracker.recordAccess(userId, today)).thenReturn(streak) + whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) + whenever(badgeRepository.findAll()).thenReturn( + listOf( + BadgeFixture.dailyAccessStreak(7), + BadgeFixture.cumulativeRetro(1), + ), + ) + whenever(userBadgeRepository.save(any())).thenAnswer { it.arguments[0] } + + val result = badgeService.awardAccessBadges(userId, today) + + assertThat(result).hasSize(1) + assertThat(result[0].conditionType).isEqualTo(BadgeConditionType.DAILY_ACCESS_STREAK) } + + private fun eq(value: UUID) = org.mockito.kotlin.eq(value) } diff --git a/src/test/kotlin/com/didit/application/achievement/BadgeConditionCheckerTest.kt b/src/test/kotlin/com/didit/application/achievement/BadgeConditionCheckerTest.kt index ce8365f3..ef10b13d 100644 --- a/src/test/kotlin/com/didit/application/achievement/BadgeConditionCheckerTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/BadgeConditionCheckerTest.kt @@ -1,7 +1,9 @@ package com.didit.application.achievement +import com.didit.domain.achievement.BadgeCondition import com.didit.domain.achievement.BadgeConditionType -import com.didit.domain.achievement.Streak +import com.didit.domain.achievement.DailyAccessStreak +import com.didit.domain.achievement.WeeklyRetroStreak import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -11,126 +13,126 @@ import java.util.UUID class BadgeConditionCheckerTest { private lateinit var checker: BadgeConditionChecker private val userId = UUID.randomUUID() + private val today = LocalDate.now() @BeforeEach fun setUp() { checker = BadgeConditionChecker() } - private fun context( - totalRetroCount: Int = 1, - streak: Streak = Streak.create(userId), - deepQuestionCount: Int = 0, - retroDate: LocalDate = LocalDate.now(), - weeklyGoalAchievedWeeks: Int = 0, + private fun ctx( + totalRetroCount: Int = 0, + currentWeekRetroCount: Int = 0, + weeklyRetroStreak: WeeklyRetroStreak = WeeklyRetroStreak.create(userId), + weeklyStreakWithMin3: Int = 0, + dailyAccessStreak: DailyAccessStreak = DailyAccessStreak.create(userId), + projectCount: Int = 0, + projectAssignedRetroCount: Int = 0, + maxRetroInOneProject: Int = 0, ) = BadgeCheckContext( userId = userId, + retroDate = today, totalRetroCount = totalRetroCount, - streak = streak, - deepQuestionCount = deepQuestionCount, - retroDate = retroDate, - weeklyGoalAchievedWeeks = weeklyGoalAchievedWeeks, + currentWeekRetroCount = currentWeekRetroCount, + weeklyRetroStreak = weeklyRetroStreak, + weeklyStreakWithMin3 = weeklyStreakWithMin3, + dailyAccessStreak = dailyAccessStreak, + projectCount = projectCount, + projectAssignedRetroCount = projectAssignedRetroCount, + maxRetroInOneProject = maxRetroInOneProject, ) @Test - fun `FIRST_RETRO - 첫 번째 회고이면 true를 반환한다`() { - val context = context(totalRetroCount = 1) + fun `CUMULATIVE_RETRO - 누적 회고 수가 threshold 이상이면 true`() { + val condition = BadgeCondition(BadgeConditionType.CUMULATIVE_RETRO, threshold = 10) - assertThat(checker.isSatisfied(BadgeConditionType.FIRST_RETRO, context)).isTrue() + assertThat(checker.isSatisfied(condition, ctx(totalRetroCount = 10))).isTrue() + assertThat(checker.isSatisfied(condition, ctx(totalRetroCount = 9))).isFalse() } @Test - fun `FIRST_RETRO - 첫 번째 회고가 아니면 false를 반환한다`() { - val context = context(totalRetroCount = 2) + fun `WEEKLY_RETRO_COUNT - 이번 주 회고 수가 threshold 이상이면 true`() { + val condition = BadgeCondition(BadgeConditionType.WEEKLY_RETRO_COUNT, threshold = 3) - assertThat(checker.isSatisfied(BadgeConditionType.FIRST_RETRO, context)).isFalse() + assertThat(checker.isSatisfied(condition, ctx(currentWeekRetroCount = 3))).isTrue() + assertThat(checker.isSatisfied(condition, ctx(currentWeekRetroCount = 2))).isFalse() } @Test - fun `STREAK_3_DAYS - 3일 연속이면 true를 반환한다`() { + fun `WEEKLY_STREAK - weeklyMin 1 일 때 currentWeeks 가 threshold 이상이면 true`() { val streak = - Streak.create(userId).apply { - val today = LocalDate.now() - update(today.minusDays(2)) - update(today.minusDays(1)) - update(today) + WeeklyRetroStreak.create(userId).apply { + recordRetro(LocalDate.of(2026, 6, 1)) + recordRetro(LocalDate.of(2026, 6, 8)) + recordRetro(LocalDate.of(2026, 6, 15)) + recordRetro(LocalDate.of(2026, 6, 22)) } - val context = context(streak = streak) - - assertThat(checker.isSatisfied(BadgeConditionType.STREAK_3_DAYS, context)).isTrue() - } - - @Test - fun `STREAK_3_DAYS - 3일 연속이 아니면 false를 반환한다`() { - val streak = - Streak.create(userId).apply { - update(LocalDate.now()) - } - val context = context(streak = streak) - - assertThat(checker.isSatisfied(BadgeConditionType.STREAK_3_DAYS, context)).isFalse() + val condition = + BadgeCondition( + BadgeConditionType.WEEKLY_STREAK, + threshold = 4, + params = mapOf("weeklyMinCount" to 1), + ) + + assertThat(checker.isSatisfied(condition, ctx(weeklyRetroStreak = streak))).isTrue() } @Test - fun `TOTAL_30 - 누적 30회 이상이면 true를 반환한다`() { - val context = context(totalRetroCount = 30) - - assertThat(checker.isSatisfied(BadgeConditionType.TOTAL_30, context)).isTrue() + fun `WEEKLY_STREAK - weeklyMin 3 일 때 weeklyStreakWithMin3 가 threshold 이상이면 true`() { + val condition = + BadgeCondition( + BadgeConditionType.WEEKLY_STREAK, + threshold = 3, + params = mapOf("weeklyMinCount" to 3), + ) + + assertThat(checker.isSatisfied(condition, ctx(weeklyStreakWithMin3 = 3))).isTrue() + assertThat(checker.isSatisfied(condition, ctx(weeklyStreakWithMin3 = 2))).isFalse() } @Test - fun `TOTAL_30 - 누적 30회 미만이면 false를 반환한다`() { - val context = context(totalRetroCount = 29) - - assertThat(checker.isSatisfied(BadgeConditionType.TOTAL_30, context)).isFalse() + fun `WEEKLY_STREAK - 지원하지 않는 weeklyMin은 false를 반환한다`() { + val condition = + BadgeCondition( + BadgeConditionType.WEEKLY_STREAK, + threshold = 2, + params = mapOf("weeklyMinCount" to 5), + ) + + assertThat(checker.isSatisfied(condition, ctx())).isFalse() } @Test - fun `DEEP_QUESTION_1 - 심화질문 1회 이상이면 true를 반환한다`() { - val context = context(deepQuestionCount = 1) - - assertThat(checker.isSatisfied(BadgeConditionType.DEEP_QUESTION_1, context)).isTrue() - } - - @Test - fun `DEEP_QUESTION_5 - 심화질문 5회 이상이면 true를 반환한다`() { - val context = context(deepQuestionCount = 5) - - assertThat(checker.isSatisfied(BadgeConditionType.DEEP_QUESTION_5, context)).isTrue() - } - - @Test - fun `DEEP_QUESTION_10 - 심화질문 10회 이상이면 true를 반환한다`() { - val context = context(deepQuestionCount = 10) - - assertThat(checker.isSatisfied(BadgeConditionType.DEEP_QUESTION_10, context)).isTrue() - } - - @Test - fun `DEEP_QUESTION_5 - 심화질문 5회 미만이면 false를 반환한다`() { - val context = context(deepQuestionCount = 4) + fun `DAILY_ACCESS_STREAK - currentStreak가 threshold 이상이면 true`() { + val streak = + DailyAccessStreak.create(userId).apply { + for (i in 6 downTo 0) recordAccess(today.minusDays(i.toLong())) + } + val condition = BadgeCondition(BadgeConditionType.DAILY_ACCESS_STREAK, threshold = 7) - assertThat(checker.isSatisfied(BadgeConditionType.DEEP_QUESTION_5, context)).isFalse() + assertThat(checker.isSatisfied(condition, ctx(dailyAccessStreak = streak))).isTrue() } @Test - fun `WEEKLY_3_FIRST - 주 3회 첫 달성이면 true를 반환한다`() { - val context = context(weeklyGoalAchievedWeeks = 1) + fun `PROJECT_COUNT - 프로젝트 수가 threshold 이상이면 true`() { + val condition = BadgeCondition(BadgeConditionType.PROJECT_COUNT, threshold = 3) - assertThat(checker.isSatisfied(BadgeConditionType.WEEKLY_3_FIRST, context)).isTrue() + assertThat(checker.isSatisfied(condition, ctx(projectCount = 3))).isTrue() + assertThat(checker.isSatisfied(condition, ctx(projectCount = 2))).isFalse() } @Test - fun `WEEKLY_3_THREE_WEEKS - 3주 이상 주 3회 달성이면 true를 반환한다`() { - val context = context(weeklyGoalAchievedWeeks = 3) + fun `PROJECT_TAGGED_RETRO - 태그된 회고 수가 threshold 이상이면 true`() { + val condition = BadgeCondition(BadgeConditionType.PROJECT_TAGGED_RETRO, threshold = 5) - assertThat(checker.isSatisfied(BadgeConditionType.WEEKLY_3_THREE_WEEKS, context)).isTrue() + assertThat(checker.isSatisfied(condition, ctx(projectAssignedRetroCount = 5))).isTrue() } @Test - fun `WEEKLY_3_THREE_WEEKS - 3주 미만이면 false를 반환한다`() { - val context = context(weeklyGoalAchievedWeeks = 2) + fun `PROJECT_RETRO_IN_ONE - 한 프로젝트 최대 회고 수가 threshold 이상이면 true`() { + val condition = BadgeCondition(BadgeConditionType.PROJECT_RETRO_IN_ONE, threshold = 3) - assertThat(checker.isSatisfied(BadgeConditionType.WEEKLY_3_THREE_WEEKS, context)).isFalse() + assertThat(checker.isSatisfied(condition, ctx(maxRetroInOneProject = 3))).isTrue() + assertThat(checker.isSatisfied(condition, ctx(maxRetroInOneProject = 2))).isFalse() } } diff --git a/src/test/kotlin/com/didit/application/achievement/BadgeQueryServiceTest.kt b/src/test/kotlin/com/didit/application/achievement/BadgeQueryServiceTest.kt index 7c1e0dfa..574aced6 100644 --- a/src/test/kotlin/com/didit/application/achievement/BadgeQueryServiceTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/BadgeQueryServiceTest.kt @@ -2,7 +2,6 @@ package com.didit.application.achievement import com.didit.application.achievement.required.BadgeRepository import com.didit.application.achievement.required.UserBadgeRepository -import com.didit.domain.achievement.Badge import com.didit.domain.achievement.BadgeConditionType import com.didit.domain.achievement.UserBadge import org.assertj.core.api.Assertions.assertThat @@ -38,18 +37,27 @@ class BadgeQueryServiceTest { } private fun badge(conditionType: BadgeConditionType) = - Badge.create( - name = conditionType.name, - description = "설명", - conditionType = conditionType, - ) + when (conditionType) { + BadgeConditionType.CUMULATIVE_RETRO -> + com.didit.support.BadgeFixture + .cumulativeRetro(1) + BadgeConditionType.DAILY_ACCESS_STREAK -> + com.didit.support.BadgeFixture + .dailyAccessStreak(7) + BadgeConditionType.PROJECT_COUNT -> + com.didit.support.BadgeFixture + .projectCount(3) + else -> + com.didit.support.BadgeFixture + .cumulativeRetro(1) + } @Test fun `findAll - 전체 배지 목록을 반환한다`() { val badges = listOf( - badge(BadgeConditionType.FIRST_RETRO), - badge(BadgeConditionType.TOTAL_30), + badge(BadgeConditionType.CUMULATIVE_RETRO), + badge(BadgeConditionType.PROJECT_COUNT), ) whenever(badgeRepository.findAll()).thenReturn(badges) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) @@ -62,7 +70,7 @@ class BadgeQueryServiceTest { @Test fun `findAll - 획득한 배지는 acquired가 true이다`() { - val badge = badge(BadgeConditionType.FIRST_RETRO) + val badge = badge(BadgeConditionType.CUMULATIVE_RETRO) whenever(badgeRepository.findAll()).thenReturn(listOf(badge)) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn( listOf(UserBadge.create(userId, badge.id)), @@ -77,7 +85,7 @@ class BadgeQueryServiceTest { @Test fun `findAll - 미획득 배지는 acquired가 false이다`() { - val badge = badge(BadgeConditionType.FIRST_RETRO) + val badge = badge(BadgeConditionType.CUMULATIVE_RETRO) whenever(badgeRepository.findAll()).thenReturn(listOf(badge)) whenever(userBadgeRepository.findAllByUserId(userId)).thenReturn(emptyList()) @@ -90,9 +98,9 @@ class BadgeQueryServiceTest { @Test fun `findRecent - 최근 획득 배지 3개를 반환한다`() { - val badge1 = badge(BadgeConditionType.FIRST_RETRO) - val badge2 = badge(BadgeConditionType.STREAK_3_DAYS) - val badge3 = badge(BadgeConditionType.TOTAL_30) + val badge1 = badge(BadgeConditionType.CUMULATIVE_RETRO) + val badge2 = badge(BadgeConditionType.DAILY_ACCESS_STREAK) + val badge3 = badge(BadgeConditionType.PROJECT_COUNT) whenever(userBadgeRepository.findTop3ByUserIdOrderByAcquiredAtDesc(userId)).thenReturn( listOf( @@ -121,8 +129,8 @@ class BadgeQueryServiceTest { @Test fun `findUnnotified - 미알림 배지를 반환하고 알림 처리한다`() { - val badge1 = badge(BadgeConditionType.FIRST_RETRO) - val badge2 = badge(BadgeConditionType.STREAK_3_DAYS) + val badge1 = badge(BadgeConditionType.CUMULATIVE_RETRO) + val badge2 = badge(BadgeConditionType.DAILY_ACCESS_STREAK) val userBadge1 = UserBadge.create(userId, badge1.id) val userBadge2 = UserBadge.create(userId, badge2.id) diff --git a/src/test/kotlin/com/didit/application/achievement/DailyAccessTrackingServiceTest.kt b/src/test/kotlin/com/didit/application/achievement/DailyAccessTrackingServiceTest.kt new file mode 100644 index 00000000..c47b2799 --- /dev/null +++ b/src/test/kotlin/com/didit/application/achievement/DailyAccessTrackingServiceTest.kt @@ -0,0 +1,68 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.required.DailyAccessStreakRepository +import com.didit.domain.achievement.DailyAccessStreak +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import java.time.LocalDate +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class DailyAccessTrackingServiceTest { + @Mock lateinit var dailyAccessStreakRepository: DailyAccessStreakRepository + + private lateinit var service: DailyAccessTrackingService + + private val userId = UUID.randomUUID() + private val today = LocalDate.now() + + @BeforeEach + fun setUp() { + service = DailyAccessTrackingService(dailyAccessStreakRepository) + } + + @Test + fun `recordAccess - 스트릭이 없으면 새로 생성한다`() { + whenever(dailyAccessStreakRepository.findByUserId(userId)).thenReturn(null) + whenever(dailyAccessStreakRepository.save(any())).thenAnswer { it.arguments[0] } + + val result = service.recordAccess(userId, today) + + assertThat(result.userId).isEqualTo(userId) + assertThat(result.currentStreak).isEqualTo(1) + } + + @Test + fun `recordAccess - 기존 스트릭의 연속일을 증가시킨다`() { + val existing = + DailyAccessStreak.create(userId).apply { + recordAccess(today.minusDays(1)) + } + whenever(dailyAccessStreakRepository.findByUserId(userId)).thenReturn(existing) + whenever(dailyAccessStreakRepository.save(any())).thenAnswer { it.arguments[0] } + + val result = service.recordAccess(userId, today) + + assertThat(result.currentStreak).isEqualTo(2) + } + + @Test + fun `recordAccess - 같은 날 중복 호출 시 currentStreak이 유지된다`() { + val existing = + DailyAccessStreak.create(userId).apply { + recordAccess(today) + } + whenever(dailyAccessStreakRepository.findByUserId(userId)).thenReturn(existing) + whenever(dailyAccessStreakRepository.save(any())).thenAnswer { it.arguments[0] } + + val result = service.recordAccess(userId, today) + + assertThat(result.currentStreak).isEqualTo(1) + } +} diff --git a/src/test/kotlin/com/didit/application/achievement/MissionQueryServiceTest.kt b/src/test/kotlin/com/didit/application/achievement/MissionQueryServiceTest.kt new file mode 100644 index 00000000..2866ede5 --- /dev/null +++ b/src/test/kotlin/com/didit/application/achievement/MissionQueryServiceTest.kt @@ -0,0 +1,237 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.exception.CurrentMissionNotFoundException +import com.didit.application.achievement.exception.MissionDefinitionNotFoundException +import com.didit.application.achievement.exception.UserLevelNotFoundException +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.retrospect.required.RetrospectiveRepository +import com.didit.domain.achievement.Mission +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.UserLevel +import com.didit.domain.achievement.UserMission +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import java.time.LocalDateTime +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class MissionQueryServiceTest { + @Mock + lateinit var userLevelRepository: UserLevelRepository + + @Mock + lateinit var userMissionRepository: UserMissionRepository + + @Mock + lateinit var missionRepository: MissionRepository + + @Mock + lateinit var retrospectiveRepository: RetrospectiveRepository + + private lateinit var missionQueryService: MissionQueryService + + private val userId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + missionQueryService = + MissionQueryService( + userLevelRepository = userLevelRepository, + userMissionRepository = userMissionRepository, + missionRepository = missionRepository, + retrospectiveRepository = retrospectiveRepository, + ) + } + + @Test + fun `Lv1 FIRST_RETRO 미션을 조회한다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 0) + val mission = Mission.firstRetro() + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.IN_PROGRESS, + progress = 0, + levelUpPopupShown = true, + failurePopupShown = true, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(1)).thenReturn(mission) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.currentLevel).isEqualTo(0) + assertThat(result.mission.type).isEqualTo("FIRST_RETRO") + assertThat(result.mission.progress).isEqualTo(0) + assertThat(result.mission.target).isEqualTo(1) + assertThat(result.mission.remainingDays).isNull() + assertThat(result.weeklyStatus).isNull() + assertThat(result.popup.exists).isFalse() + } + + @Test + fun `Lv2 TIME_LIMITED 미션의 남은 일수를 포함한다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 1) + val mission = Mission.timeLimited() + val startedAt = LocalDateTime.now().minusDays(2) + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.IN_PROGRESS, + progress = 1, + startedAt = startedAt, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(2)).thenReturn(mission) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.currentLevel).isEqualTo(1) + assertThat(result.mission.type).isEqualTo("TIME_LIMITED") + assertThat(result.mission.remainingDays).isEqualTo(5) + assertThat(result.weeklyStatus).isNull() + } + + @Test + fun `Lv3 CONSECUTIVE_WEEK 미션의 주간 회고 현황을 포함한다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 2) + val mission = Mission.consecutiveWeek(level = 3, weeks = 2) + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.IN_PROGRESS, + progress = 1, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(3)).thenReturn(mission) + whenever(retrospectiveRepository.findCompletedByUserIdAndPeriod(eq(userId), any(), any())).thenReturn( + emptyList(), + ) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.currentLevel).isEqualTo(2) + assertThat(result.mission.type).isEqualTo("CONSECUTIVE_WEEK") + assertThat(result.weeklyStatus).isNotNull() + assertThat(result.weeklyStatus!!.days).hasSize(7) + } + + @Test + fun `사용자 레벨이 없으면 UserLevelNotFoundException을 발생시킨다`() { + whenever(userLevelRepository.findByUserId(userId)).thenReturn(null) + + assertThatThrownBy { missionQueryService.getCurrentMission(userId) } + .isInstanceOf(UserLevelNotFoundException::class.java) + } + + @Test + fun `현재 미션이 없으면 CurrentMissionNotFoundException을 발생시킨다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 1) + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(null) + + assertThatThrownBy { missionQueryService.getCurrentMission(userId) } + .isInstanceOf(CurrentMissionNotFoundException::class.java) + } + + @Test + fun `미션 정의가 없으면 MissionDefinitionNotFoundException을 발생시킨다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 0) + val userMission = UserMission(userId = userId, missionId = UUID.randomUUID()) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(1)).thenReturn(null) + + assertThatThrownBy { missionQueryService.getCurrentMission(userId) } + .isInstanceOf(MissionDefinitionNotFoundException::class.java) + } + + @Test + fun `레벨업 팝업을 반환한다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 0) + val mission = Mission.firstRetro() + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.IN_PROGRESS, + levelUpPopupShown = false, + failurePopupShown = true, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(1)).thenReturn(mission) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.popup.exists).isTrue() + assertThat(result.popup.type).isEqualTo("LEVEL_UP") + } + + @Test + fun `실패 팝업을 반환한다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 0) + val mission = Mission.firstRetro() + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.WAIT_CONFIRM, + levelUpPopupShown = true, + failurePopupShown = false, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(1)).thenReturn(mission) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.popup.exists).isTrue() + assertThat(result.popup.type).isEqualTo("FAILURE") + } + + @Test + fun `팝업이 없으면 exists가 false이다`() { + val userLevel = UserLevel(userId = userId, currentLevel = 0) + val mission = Mission.firstRetro() + val userMission = + UserMission( + userId = userId, + missionId = mission.id, + status = MissionStatus.IN_PROGRESS, + levelUpPopupShown = true, + failurePopupShown = true, + ) + + whenever(userLevelRepository.findByUserId(userId)).thenReturn(userLevel) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findByLevel(1)).thenReturn(mission) + + val result = missionQueryService.getCurrentMission(userId) + + assertThat(result.popup.exists).isFalse() + assertThat(result.popup.type).isNull() + } +} diff --git a/src/test/kotlin/com/didit/application/achievement/UserMissionServiceTest.kt b/src/test/kotlin/com/didit/application/achievement/UserMissionServiceTest.kt new file mode 100644 index 00000000..6e24826f --- /dev/null +++ b/src/test/kotlin/com/didit/application/achievement/UserMissionServiceTest.kt @@ -0,0 +1,145 @@ +package com.didit.application.achievement + +import com.didit.application.achievement.exception.CurrentMissionNotFoundException +import com.didit.application.achievement.required.MissionRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.domain.achievement.Mission +import com.didit.domain.achievement.MissionStatus +import com.didit.domain.achievement.MissionType +import com.didit.domain.achievement.UserMission +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.LocalDateTime +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class UserMissionServiceTest { + @Mock + lateinit var userMissionRepository: UserMissionRepository + + @Mock + lateinit var missionRepository: MissionRepository + + @Mock + lateinit var userLevelRepository: UserLevelRepository + + private lateinit var userMissionService: UserMissionService + + private val userId = UUID.randomUUID() + private val missionId = UUID.randomUUID() + + @BeforeEach + fun setUp() { + userMissionService = UserMissionService(userMissionRepository, missionRepository, userLevelRepository) + } + + @Test + fun `레벨 정보가 없으면 UserLevel과 Lv1 미션을 생성한다`() { + whenever(userLevelRepository.existsByUserId(userId)).thenReturn(false) + whenever(missionRepository.findByLevel(1)).thenReturn(Mission.firstRetro()) + + userMissionService.ensureInitialized(userId) + + verify(userLevelRepository).save(any()) + verify(userMissionRepository).save(any()) + } + + @Test + fun `이미 레벨 정보가 있으면 아무것도 생성하지 않는다`() { + whenever(userLevelRepository.existsByUserId(userId)).thenReturn(true) + + userMissionService.ensureInitialized(userId) + + verify(userLevelRepository, never()).save(any()) + verify(userMissionRepository, never()).save(any()) + } + + @Test + fun `레벨업 팝업 확인을 처리한다`() { + val userMission = UserMission(userId = userId, missionId = missionId) + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(userMissionRepository.save(any())).thenReturn(userMission) + + userMissionService.confirmLevelUp(userId) + + assertThat(userMission.levelUpPopupShown).isTrue() + verify(userMissionRepository).save(userMission) + } + + @Test + fun `현재 미션이 없으면 CurrentMissionNotFoundException을 발생시킨다`() { + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(null) + + assertThatThrownBy { userMissionService.confirmLevelUp(userId) } + .isInstanceOf(CurrentMissionNotFoundException::class.java) + } + + @Test + fun `Lv2 미션 재도전 시 진행률을 초기화하고 시작 시각을 갱신한다`() { + val timeLimitedMission = + Mission( + id = missionId, + level = 2, + missionType = MissionType.TIME_LIMITED, + targetCount = 3, + title = "테스트", + description = "테스트", + durationDays = 7, + ) + val originalStartedAt = LocalDateTime.now().minusDays(8) + val userMission = + UserMission( + userId = userId, + missionId = missionId, + status = MissionStatus.IN_PROGRESS, + progress = 2, + startedAt = originalStartedAt, + ) + + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findAll()).thenReturn(listOf(timeLimitedMission)) + whenever(userMissionRepository.save(any())).thenReturn(userMission) + + userMissionService.retryMission(userId) + + assertThat(userMission.progress).isEqualTo(0) + assertThat(userMission.startedAt).isNotEqualTo(originalStartedAt) + assertThat(userMission.failurePopupShown).isTrue() + verify(userMissionRepository).save(userMission) + } + + @Test + fun `Lv3 미션 재도전 시 진행률을 초기화한다`() { + val consecutiveWeekMission = + Mission( + id = missionId, + level = 3, + missionType = MissionType.CONSECUTIVE_WEEK, + targetCount = 2, + title = "테스트", + description = "테스트", + ) + val userMission = + UserMission(userId = userId, missionId = missionId, status = MissionStatus.IN_PROGRESS, progress = 1) + + whenever(userMissionRepository.findCurrentMissionByUserId(userId)).thenReturn(userMission) + whenever(missionRepository.findAll()).thenReturn(listOf(consecutiveWeekMission)) + whenever(userMissionRepository.save(any())).thenReturn(userMission) + + userMissionService.retryMission(userId) + + assertThat(userMission.progress).isEqualTo(0) + assertThat(userMission.failurePopupShown).isTrue() + verify(userMissionRepository).save(userMission) + } +} diff --git a/src/test/kotlin/com/didit/application/achievement/provided/BadgeFinderTest.kt b/src/test/kotlin/com/didit/application/achievement/provided/BadgeFinderTest.kt index 627d7e66..007e7c77 100644 --- a/src/test/kotlin/com/didit/application/achievement/provided/BadgeFinderTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/provided/BadgeFinderTest.kt @@ -1,6 +1,7 @@ package com.didit.application.achievement.provided import com.didit.application.achievement.dto.BadgeResponse +import com.didit.domain.achievement.BadgeCategory import com.didit.domain.achievement.BadgeConditionType import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -20,12 +21,19 @@ class BadgeFinderTest { private fun badgeResponse( acquired: Boolean = false, - conditionType: BadgeConditionType = BadgeConditionType.FIRST_RETRO, + conditionType: BadgeConditionType = BadgeConditionType.CUMULATIVE_RETRO, + category: BadgeCategory = BadgeCategory.CONSISTENCY, + threshold: Int = 1, ) = BadgeResponse( id = UUID.randomUUID(), name = conditionType.name, description = "설명", + category = category.name, conditionType = conditionType.name, + threshold = threshold, + iconUrl = null, + congratsTitle = null, + congratsMessage = null, acquired = acquired, acquiredAt = null, ) @@ -66,8 +74,13 @@ class BadgeFinderTest { fun `findUnnotified - 미알림 배지 목록을 반환한다`() { val badges = listOf( - badgeResponse(acquired = true, conditionType = BadgeConditionType.FIRST_RETRO), - badgeResponse(acquired = true, conditionType = BadgeConditionType.STREAK_3_DAYS), + badgeResponse(acquired = true, conditionType = BadgeConditionType.CUMULATIVE_RETRO), + badgeResponse( + acquired = true, + conditionType = BadgeConditionType.DAILY_ACCESS_STREAK, + category = BadgeCategory.ACCESS, + threshold = 7, + ), ) whenever(badgeFinder.findUnnotified(userId)).thenReturn(badges) diff --git a/src/test/kotlin/com/didit/application/achievement/required/BadgeRepositoryTest.kt b/src/test/kotlin/com/didit/application/achievement/required/BadgeRepositoryTest.kt index bf21707a..1df37401 100644 --- a/src/test/kotlin/com/didit/application/achievement/required/BadgeRepositoryTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/required/BadgeRepositoryTest.kt @@ -1,7 +1,7 @@ package com.didit.application.achievement.required -import com.didit.domain.achievement.Badge import com.didit.domain.achievement.BadgeConditionType +import com.didit.support.BadgeFixture import com.didit.support.RepositoryTestSupport import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -11,27 +11,18 @@ class BadgeRepositoryTest : RepositoryTestSupport() { @Autowired lateinit var badgeRepository: BadgeRepository - private fun badge(conditionType: BadgeConditionType) = - Badge.create( - name = conditionType.name, - description = "설명", - conditionType = conditionType, - ) - @Test fun `save - 배지를 저장한다`() { - val saved = badgeRepository.save(badge(BadgeConditionType.FIRST_RETRO)) + val saved = badgeRepository.save(BadgeFixture.cumulativeRetro(1)) - assertThat(saved.conditionType).isEqualTo(BadgeConditionType.FIRST_RETRO) + assertThat(saved.conditionType).isEqualTo(BadgeConditionType.CUMULATIVE_RETRO) + assertThat(saved.condition.threshold).isEqualTo(1) } @Test - fun `findAll - 전체 배지를 반환한다`() { - badgeRepository.save(badge(BadgeConditionType.FIRST_RETRO)) - badgeRepository.save(badge(BadgeConditionType.STREAK_3_DAYS)) - - val result = badgeRepository.findAll() + fun `save - WEEKLY_STREAK 배지는 params JSON을 보존한다`() { + val saved = badgeRepository.save(BadgeFixture.weeklyStreak(threshold = 3, weeklyMin = 3)) - assertThat(result).hasSize(2) + assertThat(saved.condition.weeklyMinCount()).isEqualTo(3) } } diff --git a/src/test/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepositoryTest.kt b/src/test/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepositoryTest.kt new file mode 100644 index 00000000..6a55e13a --- /dev/null +++ b/src/test/kotlin/com/didit/application/achievement/required/DailyAccessStreakRepositoryTest.kt @@ -0,0 +1,55 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.DailyAccessStreak +import com.didit.support.RepositoryTestSupport +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDate +import java.util.UUID + +class DailyAccessStreakRepositoryTest : RepositoryTestSupport() { + @Autowired + lateinit var dailyAccessStreakRepository: DailyAccessStreakRepository + + private val userId = UUID.randomUUID() + + @Test + fun `save - 일일 접속 스트릭을 저장한다`() { + val streak = DailyAccessStreak.create(userId) + + val saved = dailyAccessStreakRepository.save(streak) + + assertThat(saved.userId).isEqualTo(userId) + assertThat(saved.currentStreak).isEqualTo(0) + } + + @Test + fun `findByUserId - 저장된 스트릭을 반환한다`() { + dailyAccessStreakRepository.save(DailyAccessStreak.create(userId)) + + val found = dailyAccessStreakRepository.findByUserId(userId) + + assertThat(found).isNotNull + assertThat(found!!.userId).isEqualTo(userId) + } + + @Test + fun `findByUserId - 존재하지 않으면 null을 반환한다`() { + val found = dailyAccessStreakRepository.findByUserId(UUID.randomUUID()) + + assertThat(found).isNull() + } + + @Test + fun `save - 일일 스트릭을 업데이트한다`() { + val streak = dailyAccessStreakRepository.save(DailyAccessStreak.create(userId)) + val today = LocalDate.now() + streak.recordAccess(today) + + val updated = dailyAccessStreakRepository.save(streak) + + assertThat(updated.currentStreak).isEqualTo(1) + assertThat(updated.lastAccessDate).isEqualTo(today) + } +} diff --git a/src/test/kotlin/com/didit/application/achievement/required/StreakRepositoryTest.kt b/src/test/kotlin/com/didit/application/achievement/required/StreakRepositoryTest.kt deleted file mode 100644 index bb54b040..00000000 --- a/src/test/kotlin/com/didit/application/achievement/required/StreakRepositoryTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.didit.application.achievement.required - -import com.didit.domain.achievement.Streak -import com.didit.support.RepositoryTestSupport -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDate -import java.util.UUID - -class StreakRepositoryTest : RepositoryTestSupport() { - @Autowired - lateinit var streakRepository: StreakRepository - - private val userId = UUID.randomUUID() - - @Test - fun `save - 스트릭을 저장한다`() { - val streak = Streak.create(userId) - - val saved = streakRepository.save(streak) - - assertThat(saved.userId).isEqualTo(userId) - assertThat(saved.currentStreak).isEqualTo(0) - } - - @Test - fun `findByUserId - 스트릭을 반환한다`() { - streakRepository.save(Streak.create(userId)) - - val found = streakRepository.findByUserId(userId) - - assertThat(found).isNotNull - assertThat(found!!.userId).isEqualTo(userId) - } - - @Test - fun `findByUserId - 존재하지 않으면 null을 반환한다`() { - val found = streakRepository.findByUserId(UUID.randomUUID()) - - assertThat(found).isNull() - } - - @Test - fun `save - 스트릭을 업데이트한다`() { - val streak = streakRepository.save(Streak.create(userId)) - streak.update(LocalDate.now()) - - val updated = streakRepository.save(streak) - - assertThat(updated.currentStreak).isEqualTo(1) - assertThat(updated.lastRetroDate).isEqualTo(LocalDate.now()) - } -} diff --git a/src/test/kotlin/com/didit/application/achievement/required/UserBadgeRepositoryTest.kt b/src/test/kotlin/com/didit/application/achievement/required/UserBadgeRepositoryTest.kt index 08c5837a..91475165 100644 --- a/src/test/kotlin/com/didit/application/achievement/required/UserBadgeRepositoryTest.kt +++ b/src/test/kotlin/com/didit/application/achievement/required/UserBadgeRepositoryTest.kt @@ -1,8 +1,8 @@ package com.didit.application.achievement.required import com.didit.domain.achievement.Badge -import com.didit.domain.achievement.BadgeConditionType import com.didit.domain.achievement.UserBadge +import com.didit.support.BadgeFixture import com.didit.support.RepositoryTestSupport import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -18,18 +18,11 @@ class UserBadgeRepositoryTest : RepositoryTestSupport() { private val userId = UUID.randomUUID() - private fun savedBadge(conditionType: BadgeConditionType): Badge = - badgeRepository.save( - Badge.create( - name = conditionType.name, - description = "설명", - conditionType = conditionType, - ), - ) + private fun savedCumulativeBadge(threshold: Int): Badge = badgeRepository.save(BadgeFixture.cumulativeRetro(threshold)) @Test fun `save - 사용자 배지를 저장한다`() { - val badge = savedBadge(BadgeConditionType.FIRST_RETRO) + val badge = savedCumulativeBadge(1) val userBadge = userBadgeRepository.save(UserBadge.create(userId, badge.id)) @@ -39,8 +32,8 @@ class UserBadgeRepositoryTest : RepositoryTestSupport() { @Test fun `findAllByUserId - 사용자의 전체 배지를 반환한다`() { - val badge1 = savedBadge(BadgeConditionType.FIRST_RETRO) - val badge2 = savedBadge(BadgeConditionType.TOTAL_30) + val badge1 = savedCumulativeBadge(1) + val badge2 = savedCumulativeBadge(30) userBadgeRepository.save(UserBadge.create(userId, badge1.id)) userBadgeRepository.save(UserBadge.create(userId, badge2.id)) @@ -52,7 +45,7 @@ class UserBadgeRepositoryTest : RepositoryTestSupport() { @Test fun `findAllByUserId - 다른 유저의 배지는 반환하지 않는다`() { - val badge = savedBadge(BadgeConditionType.FIRST_RETRO) + val badge = savedCumulativeBadge(1) userBadgeRepository.save(UserBadge.create(userId, badge.id)) userBadgeRepository.save(UserBadge.create(UUID.randomUUID(), badge.id)) @@ -64,8 +57,8 @@ class UserBadgeRepositoryTest : RepositoryTestSupport() { @Test fun `findAllByUserIdAndIsNotifiedFalse - 미알림 배지만 반환한다`() { - val badge1 = savedBadge(BadgeConditionType.FIRST_RETRO) - val badge2 = savedBadge(BadgeConditionType.STREAK_3_DAYS) + val badge1 = savedCumulativeBadge(1) + val badge2 = savedCumulativeBadge(10) userBadgeRepository.save(UserBadge.create(userId, badge1.id)) @@ -81,8 +74,16 @@ class UserBadgeRepositoryTest : RepositoryTestSupport() { @Test fun `findTop3ByUserIdOrderByAcquiredAtDesc - 최근 3개만 반환한다`() { - repeat(5) { - val badge = savedBadge(BadgeConditionType.entries[it]) + val badges = + listOf( + BadgeFixture.cumulativeRetro(1), + BadgeFixture.cumulativeRetro(10), + BadgeFixture.cumulativeRetro(30), + BadgeFixture.projectCount(3), + BadgeFixture.dailyAccessStreak(7), + ) + badges.forEach { badge -> + badgeRepository.save(badge) userBadgeRepository.save(UserBadge.create(userId, badge.id)) } diff --git a/src/test/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepositoryTest.kt b/src/test/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepositoryTest.kt new file mode 100644 index 00000000..847c13ae --- /dev/null +++ b/src/test/kotlin/com/didit/application/achievement/required/WeeklyRetroStreakRepositoryTest.kt @@ -0,0 +1,54 @@ +package com.didit.application.achievement.required + +import com.didit.domain.achievement.WeeklyRetroStreak +import com.didit.support.RepositoryTestSupport +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import java.time.LocalDate +import java.util.UUID + +class WeeklyRetroStreakRepositoryTest : RepositoryTestSupport() { + @Autowired + lateinit var weeklyRetroStreakRepository: WeeklyRetroStreakRepository + + private val userId = UUID.randomUUID() + + @Test + fun `save - 주간 회고 스트릭을 저장한다`() { + val streak = WeeklyRetroStreak.create(userId) + + val saved = weeklyRetroStreakRepository.save(streak) + + assertThat(saved.userId).isEqualTo(userId) + assertThat(saved.currentWeeks).isEqualTo(0) + } + + @Test + fun `findByUserId - 저장된 스트릭을 반환한다`() { + weeklyRetroStreakRepository.save(WeeklyRetroStreak.create(userId)) + + val found = weeklyRetroStreakRepository.findByUserId(userId) + + assertThat(found).isNotNull + assertThat(found!!.userId).isEqualTo(userId) + } + + @Test + fun `findByUserId - 존재하지 않으면 null을 반환한다`() { + val found = weeklyRetroStreakRepository.findByUserId(UUID.randomUUID()) + + assertThat(found).isNull() + } + + @Test + fun `save - 주간 스트릭을 업데이트한다`() { + val streak = weeklyRetroStreakRepository.save(WeeklyRetroStreak.create(userId)) + streak.recordRetro(LocalDate.of(2026, 6, 24)) + + val updated = weeklyRetroStreakRepository.save(streak) + + assertThat(updated.currentWeeks).isEqualTo(1) + assertThat(updated.lastAchievedWeek).isEqualTo(LocalDate.of(2026, 6, 22)) + } +} diff --git a/src/test/kotlin/com/didit/application/admin/AdminAchievementStatsServiceTest.kt b/src/test/kotlin/com/didit/application/admin/AdminAchievementStatsServiceTest.kt new file mode 100644 index 00000000..d681d895 --- /dev/null +++ b/src/test/kotlin/com/didit/application/admin/AdminAchievementStatsServiceTest.kt @@ -0,0 +1,130 @@ +package com.didit.application.admin + +import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.LevelCount +import com.didit.application.achievement.required.MissionLevelStatusCount +import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.achievement.required.UserLevelRepository +import com.didit.application.achievement.required.UserMissionRepository +import com.didit.application.auth.required.UserRepository +import com.didit.domain.achievement.Badge +import com.didit.domain.achievement.BadgeCategory +import com.didit.domain.achievement.BadgeCondition +import com.didit.domain.achievement.BadgeConditionType +import com.didit.domain.achievement.MissionStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class AdminAchievementStatsServiceTest { + @Mock + lateinit var userLevelRepository: UserLevelRepository + + @Mock + lateinit var userMissionRepository: UserMissionRepository + + @Mock + lateinit var badgeRepository: BadgeRepository + + @Mock + lateinit var userBadgeRepository: UserBadgeRepository + + @Mock + lateinit var userRepository: UserRepository + + private fun service() = + AdminAchievementStatsService( + userLevelRepository, + userMissionRepository, + badgeRepository, + userBadgeRepository, + userRepository, + ) + + private fun levelCount( + level: Int, + count: Long, + ) = object : LevelCount { + override fun getLevel() = level + + override fun getCount() = count + } + + private fun missionCount( + level: Int, + status: MissionStatus, + count: Long, + ) = object : MissionLevelStatusCount { + override fun getLevel() = level + + override fun getStatus() = status + + override fun getCount() = count + } + + @Test + fun `레벨 분포는 0부터 10까지 채우고, UserLevel 행이 없는 유저는 레벨 0으로 집계한다`() { + whenever(userLevelRepository.countGroupByLevel()).thenReturn( + listOf(levelCount(1, 5), levelCount(3, 2)), + ) + whenever(userRepository.countByDeletedAtIsNull()).thenReturn(10) + + val result = service().getLevelStats() + + assertThat(result).hasSize(11) + assertThat(result.first { it.level == 0 }.userCount).isEqualTo(3) + assertThat(result.first { it.level == 1 }.userCount).isEqualTo(5) + assertThat(result.first { it.level == 2 }.userCount).isEqualTo(0) + assertThat(result.first { it.level == 3 }.userCount).isEqualTo(2) + } + + @Test + fun `미션 통계는 상태별로 집계하고 완료율을 계산한다`() { + whenever(userMissionRepository.countGroupByLevelAndStatus()).thenReturn( + listOf( + missionCount(2, MissionStatus.IN_PROGRESS, 3), + missionCount(2, MissionStatus.COMPLETED, 5), + missionCount(2, MissionStatus.WAIT_CONFIRM, 1), + missionCount(2, MissionStatus.FAILED, 1), + ), + ) + + val result = service().getMissionStats() + + assertThat(result).hasSize(1) + val lv2 = result.first { it.level == 2 } + assertThat(lv2.inProgress).isEqualTo(3) + assertThat(lv2.completed).isEqualTo(5) + assertThat(lv2.failed).isEqualTo(2) + assertThat(lv2.total).isEqualTo(10) + assertThat(lv2.completionRate).isEqualTo(50.0) + } + + @Test + fun `배지 통계는 배지별 획득 수를 매핑한다`() { + val badgeId = UUID.randomUUID() + val badge = + Badge( + id = badgeId, + name = "첫 기록", + description = "설명", + category = BadgeCategory.CONSISTENCY, + condition = BadgeCondition(BadgeConditionType.CUMULATIVE_RETRO, 1), + ) + whenever(badgeRepository.findAll()).thenReturn(listOf(badge)) + whenever(userBadgeRepository.countByBadgeId(any())).thenReturn(42L) + + val result = service().getBadgeStats() + + assertThat(result).hasSize(1) + assertThat(result[0].badgeId).isEqualTo(badgeId) + assertThat(result[0].name).isEqualTo("첫 기록") + assertThat(result[0].acquiredCount).isEqualTo(42L) + } +} diff --git a/src/test/kotlin/com/didit/application/admin/AdminBadgeManagementServiceTest.kt b/src/test/kotlin/com/didit/application/admin/AdminBadgeManagementServiceTest.kt new file mode 100644 index 00000000..f1225208 --- /dev/null +++ b/src/test/kotlin/com/didit/application/admin/AdminBadgeManagementServiceTest.kt @@ -0,0 +1,200 @@ +package com.didit.application.admin + +import com.didit.application.achievement.required.BadgeRepository +import com.didit.application.achievement.required.UserBadgeRepository +import com.didit.application.admin.provided.AdminBadgeCreateCommand +import com.didit.application.admin.provided.AdminBadgeUpdateCommand +import com.didit.application.common.exception.BusinessException +import com.didit.domain.achievement.Badge +import com.didit.domain.achievement.BadgeCategory +import com.didit.domain.achievement.BadgeCondition +import com.didit.domain.achievement.BadgeConditionType +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import java.util.UUID + +@ExtendWith(MockitoExtension::class) +class AdminBadgeManagementServiceTest { + @Mock + lateinit var badgeRepository: BadgeRepository + + @Mock + lateinit var userBadgeRepository: UserBadgeRepository + + private lateinit var service: AdminBadgeManagementService + + private fun setUp() { + service = AdminBadgeManagementService(badgeRepository, userBadgeRepository) + } + + private fun createCommand( + category: String = "CONSISTENCY", + conditionType: String = "CUMULATIVE_RETRO", + threshold: Int = 10, + params: Map? = null, + ) = AdminBadgeCreateCommand( + name = "10회 기록", + description = "회고 10회 작성", + category = category, + conditionType = conditionType, + threshold = threshold, + params = params, + ) + + @Test + fun `배지를 생성한다`() { + setUp() + whenever(badgeRepository.save(any())).thenAnswer { it.arguments[0] as Badge } + whenever(userBadgeRepository.countByBadgeId(any())).thenReturn(0L) + + val result = service.create(createCommand()) + + assertThat(result.name).isEqualTo("10회 기록") + assertThat(result.category).isEqualTo("CONSISTENCY") + assertThat(result.conditionType).isEqualTo("CUMULATIVE_RETRO") + assertThat(result.threshold).isEqualTo(10) + assertThat(result.active).isTrue() + assertThat(result.acquiredCount).isEqualTo(0L) + } + + @Test + fun `WEEKLY_STREAK params를 보존한다`() { + setUp() + whenever(badgeRepository.save(any())).thenAnswer { it.arguments[0] as Badge } + whenever(userBadgeRepository.countByBadgeId(any())).thenReturn(0L) + + val result = + service.create( + createCommand( + category = "PATTERN", + conditionType = "WEEKLY_STREAK", + threshold = 3, + params = mapOf("weeklyMinCount" to 3), + ), + ) + + assertThat(result.params).isEqualTo(mapOf("weeklyMinCount" to 3)) + } + + @Test + fun `WEEKLY_STREAK weeklyMinCount가 1·3이 아니면 예외`() { + setUp() + + assertThatThrownBy { + service.create( + createCommand( + category = "PATTERN", + conditionType = "WEEKLY_STREAK", + threshold = 3, + params = mapOf("weeklyMinCount" to 5), + ), + ) + }.isInstanceOf(BusinessException::class.java) + } + + @Test + fun `유효하지 않은 category면 예외`() { + setUp() + + assertThatThrownBy { service.create(createCommand(category = "UNKNOWN")) } + .isInstanceOf(BusinessException::class.java) + } + + @Test + fun `유효하지 않은 conditionType이면 예외`() { + setUp() + + assertThatThrownBy { service.create(createCommand(conditionType = "UNKNOWN")) } + .isInstanceOf(BusinessException::class.java) + } + + @Test + fun `threshold가 1 미만이면 예외`() { + setUp() + + assertThatThrownBy { service.create(createCommand(threshold = 0)) } + .isInstanceOf(BusinessException::class.java) + } + + @Test + fun `배지를 수정한다`() { + setUp() + val badgeId = UUID.randomUUID() + val badge = + Badge( + id = badgeId, + name = "옛 이름", + description = "옛 설명", + category = BadgeCategory.CONSISTENCY, + condition = BadgeCondition(BadgeConditionType.CUMULATIVE_RETRO, 10), + ) + whenever(badgeRepository.findById(badgeId)).thenReturn(badge) + whenever(badgeRepository.save(any())).thenAnswer { it.arguments[0] as Badge } + whenever(userBadgeRepository.countByBadgeId(any())).thenReturn(5L) + + val result = + service.update( + badgeId, + AdminBadgeUpdateCommand( + name = "새 이름", + description = "새 설명", + category = "CONSISTENCY", + conditionType = "CUMULATIVE_RETRO", + threshold = 30, + ), + ) + + assertThat(result.name).isEqualTo("새 이름") + assertThat(result.threshold).isEqualTo(30) + assertThat(badge.name).isEqualTo("새 이름") + assertThat(badge.condition.threshold).isEqualTo(30) + } + + @Test + fun `존재하지 않는 배지 수정 시 예외`() { + setUp() + val badgeId = UUID.randomUUID() + whenever(badgeRepository.findById(badgeId)).thenReturn(null) + + assertThatThrownBy { + service.update( + badgeId, + AdminBadgeUpdateCommand( + name = "x", + description = "x", + category = "CONSISTENCY", + conditionType = "CUMULATIVE_RETRO", + threshold = 1, + ), + ) + }.isInstanceOf(BusinessException::class.java) + } + + @Test + fun `배지 활성 상태를 변경한다`() { + setUp() + val badgeId = UUID.randomUUID() + val badge = + Badge( + id = badgeId, + name = "배지", + description = "설명", + category = BadgeCategory.CONSISTENCY, + condition = BadgeCondition(BadgeConditionType.CUMULATIVE_RETRO, 10), + ) + whenever(badgeRepository.findById(badgeId)).thenReturn(badge) + whenever(badgeRepository.save(any())).thenAnswer { it.arguments[0] as Badge } + whenever(userBadgeRepository.countByBadgeId(any())).thenReturn(0L) + + val result = service.changeActive(badgeId, false) + + assertThat(result.active).isFalse() + assertThat(badge.active).isFalse() + } +} diff --git a/src/test/kotlin/com/didit/application/admin/AdminBadgeQueryServiceTest.kt b/src/test/kotlin/com/didit/application/admin/AdminBadgeQueryServiceTest.kt index 42215cb8..c01cb140 100644 --- a/src/test/kotlin/com/didit/application/admin/AdminBadgeQueryServiceTest.kt +++ b/src/test/kotlin/com/didit/application/admin/AdminBadgeQueryServiceTest.kt @@ -5,6 +5,7 @@ import com.didit.application.achievement.required.UserBadgeRepository import com.didit.application.auth.required.UserRepository import com.didit.support.UserFixture import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.InjectMocks @@ -58,4 +59,23 @@ class AdminBadgeQueryServiceTest { assertThat(result[0].userId).isEqualTo(user.id) assertThat(result[0].email).isEqualTo(user.email) } + + @Test + fun `조건 타입 메타는 7종 조건 타입과 4종 카테고리를 반환한다`() { + val meta = adminBadgeQueryService.findMeta() + + assertThat(meta.conditionTypes).hasSize(7) + assertThat(meta.categories).hasSize(4) + val weeklyStreak = meta.conditionTypes.first { it.conditionType == "WEEKLY_STREAK" } + assertThat(weeklyStreak.params).anyMatch { it.key == "weeklyMinCount" } + } + + @Test + fun `존재하지 않는 배지 상세 조회 시 예외`() { + val badgeId = UUID.randomUUID() + whenever(badgeRepository.findById(badgeId)).thenReturn(null) + + assertThatThrownBy { adminBadgeQueryService.findById(badgeId) } + .isInstanceOf(com.didit.application.common.exception.BusinessException::class.java) + } } diff --git a/src/test/kotlin/com/didit/domain/achievement/BadgeTest.kt b/src/test/kotlin/com/didit/domain/achievement/BadgeTest.kt index bbe65fd1..39f7cd0f 100644 --- a/src/test/kotlin/com/didit/domain/achievement/BadgeTest.kt +++ b/src/test/kotlin/com/didit/domain/achievement/BadgeTest.kt @@ -1,27 +1,30 @@ package com.didit.domain.achievement +import com.didit.support.BadgeFixture import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test class BadgeTest { - private fun badge(conditionType: BadgeConditionType) = - Badge.create( - name = "첫 기록", - description = "첫 회고 저장 완료", - conditionType = conditionType, - ) - @Test fun `isSameCondition - 같은 조건 타입이면 true를 반환한다`() { - val badge = badge(BadgeConditionType.FIRST_RETRO) + val badge = BadgeFixture.cumulativeRetro(1) - assertThat(badge.isSameCondition(BadgeConditionType.FIRST_RETRO)).isTrue() + assertThat(badge.isSameCondition(BadgeConditionType.CUMULATIVE_RETRO)).isTrue() } @Test fun `isSameCondition - 다른 조건 타입이면 false를 반환한다`() { - val badge = badge(BadgeConditionType.FIRST_RETRO) + val badge = BadgeFixture.cumulativeRetro(1) + + assertThat(badge.isSameCondition(BadgeConditionType.DAILY_ACCESS_STREAK)).isFalse() + } + + @Test + fun `conditionType - condition VO의 conditionType을 노출한다`() { + val badge = BadgeFixture.weeklyStreak(threshold = 3, weeklyMin = 3) - assertThat(badge.isSameCondition(BadgeConditionType.TOTAL_30)).isFalse() + assertThat(badge.conditionType).isEqualTo(BadgeConditionType.WEEKLY_STREAK) + assertThat(badge.condition.threshold).isEqualTo(3) + assertThat(badge.condition.weeklyMinCount()).isEqualTo(3) } } diff --git a/src/test/kotlin/com/didit/domain/achievement/DailyAccessStreakTest.kt b/src/test/kotlin/com/didit/domain/achievement/DailyAccessStreakTest.kt new file mode 100644 index 00000000..9dafa74d --- /dev/null +++ b/src/test/kotlin/com/didit/domain/achievement/DailyAccessStreakTest.kt @@ -0,0 +1,92 @@ +package com.didit.domain.achievement + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.util.UUID + +class DailyAccessStreakTest { + private val userId = UUID.randomUUID() + + @Test + fun `일일 접속 - 첫 접속 시 currentStreak이 1이 된다`() { + val streak = DailyAccessStreak.create(userId) + + streak.recordAccess(LocalDate.now()) + + assertThat(streak.currentStreak).isEqualTo(1) + assertThat(streak.longestStreak).isEqualTo(1) + } + + @Test + fun `일일 접속 - 연속된 날짜면 currentStreak이 증가한다`() { + val streak = DailyAccessStreak.create(userId) + val today = LocalDate.now() + + streak.recordAccess(today.minusDays(1)) + streak.recordAccess(today) + + assertThat(streak.currentStreak).isEqualTo(2) + assertThat(streak.longestStreak).isEqualTo(2) + } + + @Test + fun `일일 접속 - 같은 날 중복 호출 시 변하지 않는다`() { + val streak = DailyAccessStreak.create(userId) + val today = LocalDate.now() + + streak.recordAccess(today) + streak.recordAccess(today) + + assertThat(streak.currentStreak).isEqualTo(1) + assertThat(streak.lastAccessDate).isEqualTo(today) + } + + @Test + fun `일일 접속 - 하루 건너뛰면 currentStreak이 1로 초기화된다`() { + val streak = DailyAccessStreak.create(userId) + val today = LocalDate.now() + + streak.recordAccess(today.minusDays(3)) + streak.recordAccess(today) + + assertThat(streak.currentStreak).isEqualTo(1) + assertThat(streak.longestStreak).isEqualTo(1) + } + + @Test + fun `일일 접속 - longestStreak은 최댓값을 유지한다`() { + val streak = DailyAccessStreak.create(userId) + val today = LocalDate.now() + + streak.recordAccess(today.minusDays(6)) + streak.recordAccess(today.minusDays(5)) + streak.recordAccess(today.minusDays(4)) + streak.recordAccess(today) + + assertThat(streak.currentStreak).isEqualTo(1) + assertThat(streak.longestStreak).isEqualTo(3) + } + + @Test + fun `isStreak - 7일 연속 시 디딧 러버 조건을 충족한다`() { + val streak = DailyAccessStreak.create(userId) + val today = LocalDate.now() + + for (i in 6 downTo 0) { + streak.recordAccess(today.minusDays(i.toLong())) + } + + assertThat(streak.currentStreak).isEqualTo(7) + assertThat(streak.isStreak(7)).isTrue() + } + + @Test + fun `isStreak - 조건 미충족 시 false를 반환한다`() { + val streak = DailyAccessStreak.create(userId) + + streak.recordAccess(LocalDate.now()) + + assertThat(streak.isStreak(7)).isFalse() + } +} diff --git a/src/test/kotlin/com/didit/domain/achievement/StreakTest.kt b/src/test/kotlin/com/didit/domain/achievement/StreakTest.kt deleted file mode 100644 index 3cc8e66e..00000000 --- a/src/test/kotlin/com/didit/domain/achievement/StreakTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.didit.domain.achievement - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import java.time.LocalDate -import java.util.UUID - -class StreakTest { - private val userId = UUID.randomUUID() - - @Test - fun `연속 기록 - 첫 회고 시 streak이 1이 된다`() { - val streak = Streak.create(userId) - - streak.update(LocalDate.now()) - - assertThat(streak.currentStreak).isEqualTo(1) - assertThat(streak.longestStreak).isEqualTo(1) - } - - @Test - fun `연속 기록 - 연속된 날짜면 streak이 증가한다`() { - val streak = Streak.create(userId) - val today = LocalDate.now() - - streak.update(today.minusDays(1)) - streak.update(today) - - assertThat(streak.currentStreak).isEqualTo(2) - assertThat(streak.longestStreak).isEqualTo(2) - } - - @Test - fun `연속 기록 - 연속되지 않으면 streak이 1로 초기화된다`() { - val streak = Streak.create(userId) - val today = LocalDate.now() - - streak.update(today.minusDays(3)) - streak.update(today) - - assertThat(streak.currentStreak).isEqualTo(1) - assertThat(streak.longestStreak).isEqualTo(1) - } - - @Test - fun `연속 기록 - 같은 날 중복 호출 시 streak이 변하지 않는다`() { - val streak = Streak.create(userId) - val today = LocalDate.now() - - streak.update(today) - streak.update(today) - - assertThat(streak.currentStreak).isEqualTo(1) - } - - @Test - fun `연속 기록 - longestStreak은 최댓값을 유지한다`() { - val streak = Streak.create(userId) - val today = LocalDate.now() - - streak.update(today.minusDays(2)) - streak.update(today.minusDays(1)) - streak.update(today) - streak.update(today.plusDays(2)) - - assertThat(streak.currentStreak).isEqualTo(1) - assertThat(streak.longestStreak).isEqualTo(3) - } - - @Test - fun `isStreak - 조건 충족 시 true를 반환한다`() { - val streak = Streak.create(userId) - val today = LocalDate.now() - - streak.update(today.minusDays(2)) - streak.update(today.minusDays(1)) - streak.update(today) - - assertThat(streak.isStreak(3)).isTrue() - } - - @Test - fun `isStreak - 조건 미충족 시 false를 반환한다`() { - val streak = Streak.create(userId) - - streak.update(LocalDate.now()) - - assertThat(streak.isStreak(3)).isFalse() - } -} diff --git a/src/test/kotlin/com/didit/domain/achievement/WeeklyRetroStreakTest.kt b/src/test/kotlin/com/didit/domain/achievement/WeeklyRetroStreakTest.kt new file mode 100644 index 00000000..3b89ab18 --- /dev/null +++ b/src/test/kotlin/com/didit/domain/achievement/WeeklyRetroStreakTest.kt @@ -0,0 +1,116 @@ +package com.didit.domain.achievement + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.util.UUID + +class WeeklyRetroStreakTest { + private val userId = UUID.randomUUID() + + // 2026-06-22(월) ~ 2026-06-28(일) 을 기준 주차로 사용 + private val monday = LocalDate.of(2026, 6, 22) + private val wednesday = LocalDate.of(2026, 6, 24) + private val sunday = LocalDate.of(2026, 6, 28) + private val nextMonday = LocalDate.of(2026, 6, 29) + private val nextSunday = LocalDate.of(2026, 7, 5) + private val twoWeeksLaterMonday = LocalDate.of(2026, 7, 6) + + @Test + fun `주간 회고 - 첫 회고 시 currentWeeks가 1이 된다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(wednesday) + + assertThat(streak.currentWeeks).isEqualTo(1) + assertThat(streak.longestWeeks).isEqualTo(1) + assertThat(streak.lastAchievedWeek).isEqualTo(monday) + } + + @Test + fun `주간 회고 - 같은 주 추가 회고는 카운트가 변하지 않는다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(monday) + streak.recordRetro(wednesday) + streak.recordRetro(sunday) + + assertThat(streak.currentWeeks).isEqualTo(1) + assertThat(streak.lastAchievedWeek).isEqualTo(monday) + } + + @Test + fun `주간 회고 - 다음 주 첫 회고면 currentWeeks가 증가한다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(wednesday) + streak.recordRetro(nextMonday) + + assertThat(streak.currentWeeks).isEqualTo(2) + assertThat(streak.longestWeeks).isEqualTo(2) + assertThat(streak.lastAchievedWeek).isEqualTo(nextMonday) + } + + @Test + fun `주간 회고 - 한 주를 건너뛰면 currentWeeks가 1로 초기화된다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(monday) + streak.recordRetro(twoWeeksLaterMonday) + + assertThat(streak.currentWeeks).isEqualTo(1) + assertThat(streak.longestWeeks).isEqualTo(1) + assertThat(streak.lastAchievedWeek).isEqualTo(twoWeeksLaterMonday) + } + + @Test + fun `주간 회고 - longestWeeks는 최댓값을 유지한다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(monday) + streak.recordRetro(nextMonday) + streak.recordRetro(LocalDate.of(2026, 7, 20)) // 갭 → 리셋 + + assertThat(streak.currentWeeks).isEqualTo(1) + assertThat(streak.longestWeeks).isEqualTo(2) + } + + @Test + fun `주간 회고 - 일요일 저장과 그 다음 월요일 저장은 다른 주로 인식한다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(sunday) + streak.recordRetro(nextMonday) + + assertThat(streak.currentWeeks).isEqualTo(2) + assertThat(streak.lastAchievedWeek).isEqualTo(nextMonday) + } + + @Test + fun `주간 회고 - 주 내 어느 요일에 저장해도 lastAchievedWeek은 그 주 월요일이다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(nextSunday) + + assertThat(streak.lastAchievedWeek).isEqualTo(nextMonday) + } + + @Test + fun `isStreak - 조건 충족 시 true를 반환한다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(monday) + streak.recordRetro(nextMonday) + + assertThat(streak.isStreak(2)).isTrue() + } + + @Test + fun `isStreak - 조건 미충족 시 false를 반환한다`() { + val streak = WeeklyRetroStreak.create(userId) + + streak.recordRetro(monday) + + assertThat(streak.isStreak(2)).isFalse() + } +} diff --git a/src/test/kotlin/com/didit/support/BadgeFixture.kt b/src/test/kotlin/com/didit/support/BadgeFixture.kt new file mode 100644 index 00000000..4042a583 --- /dev/null +++ b/src/test/kotlin/com/didit/support/BadgeFixture.kt @@ -0,0 +1,72 @@ +package com.didit.support + +import com.didit.domain.achievement.Badge +import com.didit.domain.achievement.BadgeCategory +import com.didit.domain.achievement.BadgeCondition +import com.didit.domain.achievement.BadgeConditionType + +object BadgeFixture { + fun cumulativeRetro(threshold: Int): Badge = + Badge.create( + name = "${threshold}회 기록", + description = "누적 회고 ${threshold}회", + category = BadgeCategory.CONSISTENCY, + condition = BadgeCondition(BadgeConditionType.CUMULATIVE_RETRO, threshold), + ) + + fun weeklyRetroCount(threshold: Int): Badge = + Badge.create( + name = "루틴 첫걸음", + description = "한 주에 회고 ${threshold}회", + category = BadgeCategory.PATTERN, + condition = BadgeCondition(BadgeConditionType.WEEKLY_RETRO_COUNT, threshold), + ) + + fun weeklyStreak( + threshold: Int, + weeklyMin: Int, + ): Badge = + Badge.create( + name = if (weeklyMin >= 3) "루틴의 힘" else "루틴의 지속", + description = "${threshold}주 연속 주 ${weeklyMin}회", + category = BadgeCategory.PATTERN, + condition = + BadgeCondition( + conditionType = BadgeConditionType.WEEKLY_STREAK, + threshold = threshold, + params = mapOf("weeklyMinCount" to weeklyMin), + ), + ) + + fun dailyAccessStreak(threshold: Int): Badge = + Badge.create( + name = "디딧 러버", + description = "${threshold}일 연속 접속", + category = BadgeCategory.ACCESS, + condition = BadgeCondition(BadgeConditionType.DAILY_ACCESS_STREAK, threshold), + ) + + fun projectCount(threshold: Int): Badge = + Badge.create( + name = "컬렉터", + description = "프로젝트 ${threshold}개", + category = BadgeCategory.PROJECT, + condition = BadgeCondition(BadgeConditionType.PROJECT_COUNT, threshold), + ) + + fun projectTaggedRetro(threshold: Int): Badge = + Badge.create( + name = "피커", + description = "지정 회고 ${threshold}회", + category = BadgeCategory.PROJECT, + condition = BadgeCondition(BadgeConditionType.PROJECT_TAGGED_RETRO, threshold), + ) + + fun projectRetroInOne(threshold: Int): Badge = + Badge.create( + name = "디깅", + description = "한 프로젝트 ${threshold}회", + category = BadgeCategory.PROJECT, + condition = BadgeCondition(BadgeConditionType.PROJECT_RETRO_IN_ONE, threshold), + ) +}