From bac8325dabec27fbc55096d11c49c81c1b106c77 Mon Sep 17 00:00:00 2001 From: kwondonghyun Date: Thu, 9 Apr 2026 13:24:20 +0900 Subject: [PATCH 1/2] ci: add test gates before deploy + CI workflow for PRs - New ci.yml: runs BE test + FE lint/test/build on PRs to main - main.yml: deploy only runs after test-backend and test-frontend pass - SSH command_timeout increased to 15m (fixes Kafka startup timeout) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++ .github/workflows/main.yml | 60 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..eecf7f20 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + backend-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend/tiggle-root + steps: + - uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: true + + - name: Test + run: ./gradlew :tiggle:test + + frontend-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend/tiggle + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/tiggle/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9794e7ad..95a49a7d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,24 +2,78 @@ name: Deploy to NAS Server on: push: - branches: [ main ] + branches: [main] + +permissions: + contents: read jobs: + # ── Gate: Backend tests must pass before deploy ── + test-backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend/tiggle-root + steps: + - uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Test + run: ./gradlew :tiggle:test + + # ── Gate: Frontend tests must pass before deploy ── + test-frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend/tiggle + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/tiggle/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build + + # ── Deploy (only after all tests pass) ── deploy: name: Deploy + needs: [test-backend, test-frontend] runs-on: ubuntu-latest - steps: - name: Checkout code uses: actions/checkout@v4 - - name: ssh connect + - name: SSH Deploy to NAS uses: appleboy/ssh-action@v1.2.0 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.PORT }} + command_timeout: 15m script: | bash -l -c " cd Tiggle From 8ebd93760604ca0bcab0e83769025f6669055e67 Mon Sep 17 00:00:00 2001 From: kwondonghyun Date: Thu, 9 Apr 2026 13:57:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20Phase=203-2/3-3/4=20-=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C,=20=EC=97=85=EC=A0=81,=20=EC=B1=8C=EB=A6=B0?= =?UTF-8?q?=EC=A7=80,=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC,=20=EA=B2=BD?= =?UTF-8?q?=ED=97=98=EC=B9=98=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 아이템 시스템 (domain/item) - ItemCatalog: 17종 아이템 (6 슬롯 × 5 등급) - MemberItem/MemberEquipment: 보유/장착 관리 - ItemApiController: GET /inventory, /catalog, /equipment, PUT /equip ## 업적 시스템 (domain/achievement) - Achievement: 10종 업적 (기록 횟수, 연속일, 챌린지, 카테고리 등) - AchievementService: 조건 자동 체크 + 보상 지급 - AchievementApiController: GET /, /recent ## 챌린지 시스템 (domain/challenge) - Challenge: 무소비 챌린지 생성/관리/취소 - ChallengeDailyLog: 일별 무소비 판정 기록 - ChallengeScheduler: 매일 0:30 자동 판정 - ChallengeApiController: POST /, GET /active, /{id}, /history, DELETE /{id} ## 스케줄러 - StatisticsScheduler: 매주 월요일 1:00 주간 통계 자동 집계 - SchedulerConfig: @EnableScheduling ## 거래 → 캐릭터 연동 - TransactionServiceImpl.createTransaction 후 캐릭터 경험치 +10, 알 기록 +1 ## DB 마이그레이션 - V3: item_catalog, member_items, member_equipment, achievements, member_achievements, challenges, challenge_daily_logs + 시드 데이터 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/AchievementApiController.kt | 34 ++++ .../dto/resp/AchievementRespDto.kt | 15 ++ .../exception/AchievementException.kt | 19 ++ .../exception/error/AchievementErrorCode.kt | 25 +++ .../domain/achievement/model/Achievement.kt | 33 ++++ .../model/AchievementConditionType.kt | 13 ++ .../achievement/model/MemberAchievement.kt | 24 +++ .../repository/AchievementRepository.kt | 10 + .../repository/MemberAchievementRepository.kt | 9 + .../achievement/service/AchievementService.kt | 17 ++ .../service/AchievementServiceImpl.kt | 99 ++++++++++ .../challenge/api/ChallengeApiController.kt | 83 ++++++++ .../dto/req/ChallengeCreateReqDto.kt | 19 ++ .../dto/resp/ChallengeDetailRespDto.kt | 6 + .../challenge/dto/resp/ChallengeRespDto.kt | 33 ++++ .../challenge/dto/resp/DailyLogRespDto.kt | 20 ++ .../challenge/exception/ChallengeException.kt | 23 +++ .../exception/error/ChallengeErrorCode.kt | 33 ++++ .../domain/challenge/model/Challenge.kt | 45 +++++ .../challenge/model/ChallengeDailyLog.kt | 28 +++ .../domain/challenge/model/ChallengeStatus.kt | 8 + .../domain/challenge/model/ChallengeType.kt | 6 + .../repository/ChallengeDailyLogRepository.kt | 12 ++ .../repository/ChallengeRepository.kt | 16 ++ .../challenge/scheduler/ChallengeScheduler.kt | 26 +++ .../challenge/service/ChallengeService.kt | 21 +++ .../challenge/service/ChallengeServiceImpl.kt | 177 ++++++++++++++++++ .../domain/item/api/ItemApiController.kt | 61 ++++++ .../domain/item/dto/req/EquipItemReqDto.kt | 8 + .../domain/item/dto/resp/EquipmentRespDto.kt | 10 + .../item/dto/resp/ItemCatalogRespDto.kt | 16 ++ .../domain/item/dto/resp/MemberItemRespDto.kt | 15 ++ .../domain/item/exception/ItemException.kt | 19 ++ .../item/exception/error/ItemErrorCode.kt | 27 +++ .../tiggle/domain/item/model/ItemCatalog.kt | 41 ++++ .../side/tiggle/domain/item/model/ItemSlot.kt | 5 + .../side/tiggle/domain/item/model/ItemTier.kt | 5 + .../domain/item/model/MemberEquipment.kt | 36 ++++ .../tiggle/domain/item/model/MemberItem.kt | 27 +++ .../tiggle/domain/item/model/UnlockType.kt | 5 + .../item/repository/ItemCatalogRepository.kt | 11 ++ .../repository/MemberEquipmentRepository.kt | 8 + .../item/repository/MemberItemRepository.kt | 9 + .../tiggle/domain/item/service/ItemService.kt | 19 ++ .../domain/item/service/ItemServiceImpl.kt | 138 ++++++++++++++ .../scheduler/StatisticsScheduler.kt | 42 +++++ .../service/TransactionServiceImpl.kt | 12 +- .../tiggle/global/config/SchedulerConfig.kt | 8 + .../service/TransactionServiceImplTest.kt | 5 +- 49 files changed, 1379 insertions(+), 2 deletions(-) create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/api/AchievementApiController.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/dto/resp/AchievementRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/AchievementException.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/error/AchievementErrorCode.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/Achievement.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/AchievementConditionType.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/MemberAchievement.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/AchievementRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/MemberAchievementRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementService.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementServiceImpl.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/api/ChallengeApiController.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/req/ChallengeCreateReqDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeDetailRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/DailyLogRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/ChallengeException.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/error/ChallengeErrorCode.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/Challenge.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeDailyLog.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeStatus.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeType.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeDailyLogRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/scheduler/ChallengeScheduler.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeService.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeServiceImpl.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/api/ItemApiController.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/req/EquipItemReqDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/EquipmentRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/ItemCatalogRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/MemberItemRespDto.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/ItemException.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/error/ItemErrorCode.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemCatalog.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemSlot.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemTier.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberEquipment.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberItem.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/UnlockType.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/ItemCatalogRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberEquipmentRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberItemRepository.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemService.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemServiceImpl.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/statistics/scheduler/StatisticsScheduler.kt create mode 100644 backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/global/config/SchedulerConfig.kt diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/api/AchievementApiController.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/api/AchievementApiController.kt new file mode 100644 index 00000000..15a7ae04 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/api/AchievementApiController.kt @@ -0,0 +1,34 @@ +package com.side.tiggle.domain.achievement.api + +import com.side.tiggle.domain.achievement.dto.resp.AchievementRespDto +import com.side.tiggle.domain.achievement.service.AchievementService +import com.side.tiggle.global.common.ApiResponse +import com.side.tiggle.global.common.constants.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* + +@Validated +@RestController +@RequestMapping("/api/v1/achievements") +class AchievementApiController( + private val achievementService: AchievementService +) { + + @GetMapping + fun getAllAchievements( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long + ): ResponseEntity>> { + val achievements = achievementService.getAllAchievements(memberId) + return ResponseEntity.ok(ApiResponse.success(achievements)) + } + + @GetMapping("/recent") + fun getRecentAchievements( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @RequestParam(defaultValue = "5") limit: Int + ): ResponseEntity>> { + val achievements = achievementService.getRecentAchievements(memberId, limit) + return ResponseEntity.ok(ApiResponse.success(achievements)) + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/dto/resp/AchievementRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/dto/resp/AchievementRespDto.kt new file mode 100644 index 00000000..6083d7a9 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/dto/resp/AchievementRespDto.kt @@ -0,0 +1,15 @@ +package com.side.tiggle.domain.achievement.dto.resp + +import com.side.tiggle.domain.achievement.model.AchievementConditionType +import java.time.LocalDateTime + +data class AchievementRespDto( + val id: Long, + val code: String, + val name: String, + val description: String?, + val conditionType: AchievementConditionType, + val conditionValue: Int, + val achieved: Boolean, + val achievedAt: LocalDateTime? +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/AchievementException.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/AchievementException.kt new file mode 100644 index 00000000..021c4cc0 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/AchievementException.kt @@ -0,0 +1,19 @@ +package com.side.tiggle.domain.achievement.exception + +import com.side.tiggle.global.exception.CustomException +import com.side.tiggle.global.exception.error.ErrorCode + +class AchievementException : CustomException { + + private val errorCode: ErrorCode + + constructor(errorCode: ErrorCode) : super(errorCode) { + this.errorCode = errorCode + } + + constructor(errorCode: ErrorCode, cause: Throwable) : super(errorCode, cause) { + this.errorCode = errorCode + } + + override fun getErrorCode(): ErrorCode = errorCode +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/error/AchievementErrorCode.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/error/AchievementErrorCode.kt new file mode 100644 index 00000000..d8b67289 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/exception/error/AchievementErrorCode.kt @@ -0,0 +1,25 @@ +package com.side.tiggle.domain.achievement.exception.error + +import com.side.tiggle.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +/** + * 업적(Achievement) 도메인 에러 코드 정의 + * + * DD=86 (Achievement 도메인) + */ +enum class AchievementErrorCode( + private val status: HttpStatus, + private val code: Int, + private val msg: String +) : ErrorCode { + // 조회 관련 오류 (86001~86010) + ACHIEVEMENT_NOT_FOUND(HttpStatus.NOT_FOUND, 86001, "업적을 찾을 수 없습니다"), + + // 상태 관련 오류 (86011~86020) + ACHIEVEMENT_ALREADY_ACHIEVED(HttpStatus.CONFLICT, 86011, "이미 달성한 업적입니다"); + + override fun httpStatus(): HttpStatus = status + override fun codeNumber(): Int = code + override fun message(): String = msg +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/Achievement.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/Achievement.kt new file mode 100644 index 00000000..0f6a54c9 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/Achievement.kt @@ -0,0 +1,33 @@ +package com.side.tiggle.domain.achievement.model + +import jakarta.persistence.* + +@Entity +@Table(name = "achievements") +class Achievement( + @Column(nullable = false, unique = true, length = 50) + val code: String, + + @Column(nullable = false, length = 100) + val name: String, + + @Column(length = 500) + val description: String? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "condition_type", nullable = false, length = 30) + val conditionType: AchievementConditionType, + + @Column(name = "condition_value", nullable = false) + val conditionValue: Int, + + @Column(name = "reward_item_id") + val rewardItemId: Long? = null, + + @Column(name = "reward_exp", nullable = false) + val rewardExp: Int = 0 +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/AchievementConditionType.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/AchievementConditionType.kt new file mode 100644 index 00000000..e6b39588 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/AchievementConditionType.kt @@ -0,0 +1,13 @@ +package com.side.tiggle.domain.achievement.model + +enum class AchievementConditionType { + RECORD_COUNT, + STREAK, + CHALLENGE_COMPLETE, + CATEGORY_COUNT, + SPENDING_DECREASE, + NO_ANOMALY_WEEKS, + NO_SPEND_DAYS, + COLOR_RARITY, + CHARACTER_TIER +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/MemberAchievement.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/MemberAchievement.kt new file mode 100644 index 00000000..603be9bb --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/model/MemberAchievement.kt @@ -0,0 +1,24 @@ +package com.side.tiggle.domain.achievement.model + +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table( + name = "member_achievements", + uniqueConstraints = [UniqueConstraint(columnNames = ["member_id", "achievement_id"])] +) +class MemberAchievement( + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Column(name = "achievement_id", nullable = false) + val achievementId: Long, + + @Column(name = "achieved_at", nullable = false) + val achievedAt: LocalDateTime = LocalDateTime.now() +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/AchievementRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/AchievementRepository.kt new file mode 100644 index 00000000..eb506f4d --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/AchievementRepository.kt @@ -0,0 +1,10 @@ +package com.side.tiggle.domain.achievement.repository + +import com.side.tiggle.domain.achievement.model.Achievement +import com.side.tiggle.domain.achievement.model.AchievementConditionType +import org.springframework.data.jpa.repository.JpaRepository + +interface AchievementRepository : JpaRepository { + fun findByCode(code: String): Achievement? + fun findByConditionType(conditionType: AchievementConditionType): List +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/MemberAchievementRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/MemberAchievementRepository.kt new file mode 100644 index 00000000..d51fa802 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/repository/MemberAchievementRepository.kt @@ -0,0 +1,9 @@ +package com.side.tiggle.domain.achievement.repository + +import com.side.tiggle.domain.achievement.model.MemberAchievement +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberAchievementRepository : JpaRepository { + fun findAllByMemberId(memberId: Long): List + fun existsByMemberIdAndAchievementId(memberId: Long, achievementId: Long): Boolean +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementService.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementService.kt new file mode 100644 index 00000000..90c8a652 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementService.kt @@ -0,0 +1,17 @@ +package com.side.tiggle.domain.achievement.service + +import com.side.tiggle.domain.achievement.dto.resp.AchievementRespDto +import com.side.tiggle.domain.achievement.model.AchievementConditionType + +interface AchievementService { + + fun getAllAchievements(memberId: Long): List + + fun getRecentAchievements(memberId: Long, limit: Int = 5): List + + fun checkAndGrantAchievements( + memberId: Long, + conditionType: AchievementConditionType, + currentValue: Int + ) +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementServiceImpl.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementServiceImpl.kt new file mode 100644 index 00000000..ac6a8139 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/achievement/service/AchievementServiceImpl.kt @@ -0,0 +1,99 @@ +package com.side.tiggle.domain.achievement.service + +import com.side.tiggle.domain.achievement.dto.resp.AchievementRespDto +import com.side.tiggle.domain.achievement.model.AchievementConditionType +import com.side.tiggle.domain.achievement.model.MemberAchievement +import com.side.tiggle.domain.achievement.repository.AchievementRepository +import com.side.tiggle.domain.achievement.repository.MemberAchievementRepository +import com.side.tiggle.domain.character.service.CharacterService +import com.side.tiggle.domain.item.service.ItemService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AchievementServiceImpl( + private val achievementRepository: AchievementRepository, + private val memberAchievementRepository: MemberAchievementRepository, + private val itemService: ItemService, + private val characterService: CharacterService +) : AchievementService { + + override fun getAllAchievements(memberId: Long): List { + val allAchievements = achievementRepository.findAll() + val memberAchievements = memberAchievementRepository.findAllByMemberId(memberId) + val achievedMap = memberAchievements.associateBy { it.achievementId } + + return allAchievements.map { achievement -> + val memberAchievement = achievedMap[achievement.id] + AchievementRespDto( + id = achievement.id!!, + code = achievement.code, + name = achievement.name, + description = achievement.description, + conditionType = achievement.conditionType, + conditionValue = achievement.conditionValue, + achieved = memberAchievement != null, + achievedAt = memberAchievement?.achievedAt + ) + } + } + + override fun getRecentAchievements(memberId: Long, limit: Int): List { + val memberAchievements = memberAchievementRepository.findAllByMemberId(memberId) + .sortedByDescending { it.achievedAt } + .take(limit) + + val achievementIds = memberAchievements.map { it.achievementId } + val achievementMap = achievementRepository.findAllById(achievementIds).associateBy { it.id } + + return memberAchievements.mapNotNull { ma -> + val achievement = achievementMap[ma.achievementId] ?: return@mapNotNull null + AchievementRespDto( + id = achievement.id!!, + code = achievement.code, + name = achievement.name, + description = achievement.description, + conditionType = achievement.conditionType, + conditionValue = achievement.conditionValue, + achieved = true, + achievedAt = ma.achievedAt + ) + } + } + + @Transactional + override fun checkAndGrantAchievements( + memberId: Long, + conditionType: AchievementConditionType, + currentValue: Int + ) { + val candidates = achievementRepository.findByConditionType(conditionType) + + for (achievement in candidates) { + // Skip if already achieved + if (memberAchievementRepository.existsByMemberIdAndAchievementId(memberId, achievement.id!!)) { + continue + } + + // Check if condition is met + if (currentValue >= achievement.conditionValue) { + // Grant achievement + val memberAchievement = MemberAchievement( + memberId = memberId, + achievementId = achievement.id!! + ) + memberAchievementRepository.save(memberAchievement) + + // Grant reward item if any + if (achievement.rewardItemId != null) { + itemService.grantItem(memberId, achievement.rewardItemId!!, "업적 달성: ${achievement.name}") + } + + // Grant reward experience if any + if (achievement.rewardExp > 0) { + characterService.addExperience(memberId, achievement.rewardExp, "업적 달성: ${achievement.name}") + } + } + } + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/api/ChallengeApiController.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/api/ChallengeApiController.kt new file mode 100644 index 00000000..527e2f1c --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/api/ChallengeApiController.kt @@ -0,0 +1,83 @@ +package com.side.tiggle.domain.challenge.api + +import com.side.tiggle.domain.challenge.dto.req.ChallengeCreateReqDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeDetailRespDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeRespDto +import com.side.tiggle.domain.challenge.service.ChallengeService +import com.side.tiggle.global.common.ApiResponse +import com.side.tiggle.global.common.constants.HttpHeaders +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* + +@Validated +@RestController +@RequestMapping("/api/v1/challenges") +class ChallengeApiController( + private val challengeService: ChallengeService +) { + + /** + * 챌린지 생성 + */ + @PostMapping + fun createChallenge( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @Valid @RequestBody dto: ChallengeCreateReqDto + ): ResponseEntity> { + val result = challengeService.createChallenge(memberId, dto) + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(result)) + } + + /** + * 현재 활성 챌린지 조회 + */ + @GetMapping("/active") + fun getActiveChallenge( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long + ): ResponseEntity> { + val result = challengeService.getActiveChallenge(memberId) + return ResponseEntity.ok(ApiResponse.success(result)) + } + + /** + * 챌린지 상세 조회 (일일 로그 포함) + */ + @GetMapping("/{id}") + fun getChallengeDetail( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @PathVariable id: Long + ): ResponseEntity> { + val result = challengeService.getChallengeDetail(memberId, id) + return ResponseEntity.ok(ApiResponse.success(result)) + } + + /** + * 챌린지 이력 조회 (페이징) + */ + @GetMapping("/history") + fun getChallengeHistory( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "10") size: Int + ): ResponseEntity>> { + val result = challengeService.getChallengeHistory(memberId, page, size) + return ResponseEntity.ok(ApiResponse.success(result)) + } + + /** + * 챌린지 취소 + */ + @DeleteMapping("/{id}") + fun cancelChallenge( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @PathVariable id: Long + ): ResponseEntity> { + challengeService.cancelChallenge(memberId, id) + return ResponseEntity.ok(ApiResponse.success(null)) + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/req/ChallengeCreateReqDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/req/ChallengeCreateReqDto.kt new file mode 100644 index 00000000..ef10f956 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/req/ChallengeCreateReqDto.kt @@ -0,0 +1,19 @@ +package com.side.tiggle.domain.challenge.dto.req + +import com.side.tiggle.domain.challenge.model.ChallengeType +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import java.time.LocalDate + +data class ChallengeCreateReqDto( + val type: ChallengeType = ChallengeType.NO_SPEND, + + @field:NotNull(message = "시작일은 필수입니다") + val startDate: LocalDate, + + @field:NotNull(message = "종료일은 필수입니다") + val endDate: LocalDate, + + @field:Min(value = 1, message = "목표 일수는 1일 이상이어야 합니다") + val targetDays: Int +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeDetailRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeDetailRespDto.kt new file mode 100644 index 00000000..35b1692e --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeDetailRespDto.kt @@ -0,0 +1,6 @@ +package com.side.tiggle.domain.challenge.dto.resp + +data class ChallengeDetailRespDto( + val challenge: ChallengeRespDto, + val dailyLogs: List +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeRespDto.kt new file mode 100644 index 00000000..fcaece2c --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/ChallengeRespDto.kt @@ -0,0 +1,33 @@ +package com.side.tiggle.domain.challenge.dto.resp + +import com.side.tiggle.domain.challenge.model.Challenge +import com.side.tiggle.domain.challenge.model.ChallengeStatus +import com.side.tiggle.domain.challenge.model.ChallengeType +import java.time.LocalDate +import java.time.LocalDateTime + +data class ChallengeRespDto( + val id: Long, + val type: ChallengeType, + val status: ChallengeStatus, + val startDate: LocalDate, + val endDate: LocalDate, + val targetDays: Int, + val achievedDays: Int, + val createdAt: LocalDateTime +) { + companion object { + fun fromEntity(challenge: Challenge): ChallengeRespDto { + return ChallengeRespDto( + id = challenge.id!!, + type = challenge.type, + status = challenge.status, + startDate = challenge.startDate, + endDate = challenge.endDate, + targetDays = challenge.targetDays, + achievedDays = challenge.achievedDays, + createdAt = challenge.createdAt + ) + } + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/DailyLogRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/DailyLogRespDto.kt new file mode 100644 index 00000000..9aae6b9e --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/dto/resp/DailyLogRespDto.kt @@ -0,0 +1,20 @@ +package com.side.tiggle.domain.challenge.dto.resp + +import com.side.tiggle.domain.challenge.model.ChallengeDailyLog +import java.time.LocalDate + +data class DailyLogRespDto( + val logDate: LocalDate, + val isNoSpend: Boolean, + val outcomeAmount: Int +) { + companion object { + fun fromEntity(log: ChallengeDailyLog): DailyLogRespDto { + return DailyLogRespDto( + logDate = log.logDate, + isNoSpend = log.isNoSpend, + outcomeAmount = log.outcomeAmount + ) + } + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/ChallengeException.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/ChallengeException.kt new file mode 100644 index 00000000..c2f664db --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/ChallengeException.kt @@ -0,0 +1,23 @@ +package com.side.tiggle.domain.challenge.exception + +import com.side.tiggle.global.exception.CustomException +import com.side.tiggle.global.exception.error.ErrorCode + +/** + * 챌린지(Challenge) 도메인 예외 클래스 + * 챌린지 관련 비즈니스 로직 처리 중 발생하는 예외를 처리 + */ +class ChallengeException : CustomException { + + private val errorCode: ErrorCode + + constructor(errorCode: ErrorCode) : super(errorCode) { + this.errorCode = errorCode + } + + constructor(errorCode: ErrorCode, cause: Throwable) : super(errorCode, cause) { + this.errorCode = errorCode + } + + override fun getErrorCode(): ErrorCode = errorCode +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/error/ChallengeErrorCode.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/error/ChallengeErrorCode.kt new file mode 100644 index 00000000..b57b3410 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/exception/error/ChallengeErrorCode.kt @@ -0,0 +1,33 @@ +package com.side.tiggle.domain.challenge.exception.error + +import com.side.tiggle.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +/** + * 챌린지(Challenge) 도메인 에러 코드 정의 + * + * DD=85 (Challenge 도메인) + */ +enum class ChallengeErrorCode( + private val status: HttpStatus, + private val code: Int, + private val msg: String +) : ErrorCode { + + // 조회 관련 오류 (85001~85010) + CHALLENGE_NOT_FOUND(HttpStatus.NOT_FOUND, 85001, "챌린지를 찾을 수 없습니다"), + + // 생성 관련 오류 (85011~85020) + ACTIVE_CHALLENGE_EXISTS(HttpStatus.CONFLICT, 85011, "이미 진행 중인 챌린지가 있습니다"), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, 85012, "종료일은 시작일 이후여야 합니다"), + + // 권한 관련 오류 (85021~85030) + CHALLENGE_ACCESS_DENIED(HttpStatus.FORBIDDEN, 85021, "해당 챌린지에 대한 권한이 없습니다"), + + // 상태 관련 오류 (85031~85040) + CHALLENGE_NOT_ACTIVE(HttpStatus.BAD_REQUEST, 85031, "활성 상태의 챌린지가 아닙니다"); + + override fun httpStatus(): HttpStatus = status + override fun codeNumber(): Int = code + override fun message(): String = msg +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/Challenge.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/Challenge.kt new file mode 100644 index 00000000..36315114 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/Challenge.kt @@ -0,0 +1,45 @@ +package com.side.tiggle.domain.challenge.model + +import jakarta.persistence.* +import java.time.LocalDate +import java.time.LocalDateTime + +@Entity +@Table( + name = "challenges", + indexes = [Index(name = "idx_member_status", columnList = "member_id, status")] +) +class Challenge( + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + val type: ChallengeType = ChallengeType.NO_SPEND, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: ChallengeStatus = ChallengeStatus.ACTIVE, + + @Column(name = "start_date", nullable = false) + val startDate: LocalDate, + + @Column(name = "end_date", nullable = false) + val endDate: LocalDate, + + @Column(name = "target_days", nullable = false) + val targetDays: Int, + + @Column(name = "achieved_days", nullable = false) + var achievedDays: Int = 0 +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @Column(name = "created_at") + var createdAt: LocalDateTime = LocalDateTime.now() + + @Column(name = "updated_at") + var updatedAt: LocalDateTime = LocalDateTime.now() +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeDailyLog.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeDailyLog.kt new file mode 100644 index 00000000..a9b478b4 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeDailyLog.kt @@ -0,0 +1,28 @@ +package com.side.tiggle.domain.challenge.model + +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table( + name = "challenge_daily_logs", + uniqueConstraints = [UniqueConstraint(name = "uk_challenge_date", columnNames = ["challenge_id", "log_date"])], + indexes = [Index(name = "idx_challenge_id", columnList = "challenge_id")] +) +class ChallengeDailyLog( + @Column(name = "challenge_id", nullable = false) + val challengeId: Long, + + @Column(name = "log_date", nullable = false) + val logDate: LocalDate, + + @Column(name = "is_no_spend", nullable = false) + val isNoSpend: Boolean, + + @Column(name = "outcome_amount", nullable = false) + val outcomeAmount: Int = 0 +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeStatus.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeStatus.kt new file mode 100644 index 00000000..87897047 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeStatus.kt @@ -0,0 +1,8 @@ +package com.side.tiggle.domain.challenge.model + +enum class ChallengeStatus { + ACTIVE, + COMPLETED, + FAILED, + CANCELLED +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeType.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeType.kt new file mode 100644 index 00000000..e9c07edd --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/model/ChallengeType.kt @@ -0,0 +1,6 @@ +package com.side.tiggle.domain.challenge.model + +enum class ChallengeType { + NO_SPEND, + BUDGET_LIMIT +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeDailyLogRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeDailyLogRepository.kt new file mode 100644 index 00000000..30d343b6 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeDailyLogRepository.kt @@ -0,0 +1,12 @@ +package com.side.tiggle.domain.challenge.repository + +import com.side.tiggle.domain.challenge.model.ChallengeDailyLog +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface ChallengeDailyLogRepository : JpaRepository { + + fun findByChallengeIdOrderByLogDateDesc(challengeId: Long): List + + fun findByChallengeIdAndLogDate(challengeId: Long, logDate: LocalDate): ChallengeDailyLog? +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeRepository.kt new file mode 100644 index 00000000..701b43da --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/repository/ChallengeRepository.kt @@ -0,0 +1,16 @@ +package com.side.tiggle.domain.challenge.repository + +import com.side.tiggle.domain.challenge.model.Challenge +import com.side.tiggle.domain.challenge.model.ChallengeStatus +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository + +interface ChallengeRepository : JpaRepository { + + fun findByMemberIdAndStatus(memberId: Long, status: ChallengeStatus): Challenge? + + fun findAllByMemberIdOrderByCreatedAtDesc(memberId: Long, pageable: Pageable): Page + + fun findAllByStatus(status: ChallengeStatus): List +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/scheduler/ChallengeScheduler.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/scheduler/ChallengeScheduler.kt new file mode 100644 index 00000000..bbd1fa96 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/scheduler/ChallengeScheduler.kt @@ -0,0 +1,26 @@ +package com.side.tiggle.domain.challenge.scheduler + +import com.side.tiggle.domain.challenge.service.ChallengeService +import com.side.tiggle.global.common.logging.log +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class ChallengeScheduler( + private val challengeService: ChallengeService +) { + + /** + * 매일 새벽 0시 30분에 챌린지 일일 처리 실행 + */ + @Scheduled(cron = "0 30 0 * * *") + fun processDailyChallenges() { + log.info("챌린지 일일 처리 시작") + try { + challengeService.processDaily() + log.info("챌린지 일일 처리 완료") + } catch (e: Exception) { + log.error("챌린지 일일 처리 중 오류 발생", e) + } + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeService.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeService.kt new file mode 100644 index 00000000..503e5492 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeService.kt @@ -0,0 +1,21 @@ +package com.side.tiggle.domain.challenge.service + +import com.side.tiggle.domain.challenge.dto.req.ChallengeCreateReqDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeDetailRespDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeRespDto +import org.springframework.data.domain.Page + +interface ChallengeService { + + fun createChallenge(memberId: Long, dto: ChallengeCreateReqDto): ChallengeRespDto + + fun getActiveChallenge(memberId: Long): ChallengeRespDto? + + fun getChallengeDetail(memberId: Long, challengeId: Long): ChallengeDetailRespDto + + fun getChallengeHistory(memberId: Long, page: Int, size: Int): Page + + fun cancelChallenge(memberId: Long, challengeId: Long) + + fun processDaily() +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeServiceImpl.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeServiceImpl.kt new file mode 100644 index 00000000..9417cefe --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/challenge/service/ChallengeServiceImpl.kt @@ -0,0 +1,177 @@ +package com.side.tiggle.domain.challenge.service + +import com.side.tiggle.domain.challenge.dto.req.ChallengeCreateReqDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeDetailRespDto +import com.side.tiggle.domain.challenge.dto.resp.ChallengeRespDto +import com.side.tiggle.domain.challenge.dto.resp.DailyLogRespDto +import com.side.tiggle.domain.challenge.exception.ChallengeException +import com.side.tiggle.domain.challenge.exception.error.ChallengeErrorCode +import com.side.tiggle.domain.challenge.model.Challenge +import com.side.tiggle.domain.challenge.model.ChallengeDailyLog +import com.side.tiggle.domain.challenge.model.ChallengeStatus +import com.side.tiggle.domain.challenge.repository.ChallengeDailyLogRepository +import com.side.tiggle.domain.challenge.repository.ChallengeRepository +import com.side.tiggle.domain.transaction.repository.TransactionRepository +import com.side.tiggle.global.common.logging.log +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +class ChallengeServiceImpl( + private val challengeRepository: ChallengeRepository, + private val challengeDailyLogRepository: ChallengeDailyLogRepository, + private val transactionRepository: TransactionRepository +) : ChallengeService { + + @Transactional + override fun createChallenge(memberId: Long, dto: ChallengeCreateReqDto): ChallengeRespDto { + // Validate date range + if (dto.endDate.isBefore(dto.startDate)) { + throw ChallengeException(ChallengeErrorCode.INVALID_DATE_RANGE) + } + + // Check max 1 active challenge per member + val existing = challengeRepository.findByMemberIdAndStatus(memberId, ChallengeStatus.ACTIVE) + if (existing != null) { + throw ChallengeException(ChallengeErrorCode.ACTIVE_CHALLENGE_EXISTS) + } + + val challenge = Challenge( + memberId = memberId, + type = dto.type, + startDate = dto.startDate, + endDate = dto.endDate, + targetDays = dto.targetDays + ) + + val saved = challengeRepository.save(challenge) + return ChallengeRespDto.fromEntity(saved) + } + + @Transactional(readOnly = true) + override fun getActiveChallenge(memberId: Long): ChallengeRespDto? { + val challenge = challengeRepository.findByMemberIdAndStatus(memberId, ChallengeStatus.ACTIVE) + ?: return null + return ChallengeRespDto.fromEntity(challenge) + } + + @Transactional(readOnly = true) + override fun getChallengeDetail(memberId: Long, challengeId: Long): ChallengeDetailRespDto { + val challenge = challengeRepository.findById(challengeId) + .orElseThrow { ChallengeException(ChallengeErrorCode.CHALLENGE_NOT_FOUND) } + + if (challenge.memberId != memberId) { + throw ChallengeException(ChallengeErrorCode.CHALLENGE_ACCESS_DENIED) + } + + val dailyLogs = challengeDailyLogRepository.findByChallengeIdOrderByLogDateDesc(challengeId) + .map { DailyLogRespDto.fromEntity(it) } + + return ChallengeDetailRespDto( + challenge = ChallengeRespDto.fromEntity(challenge), + dailyLogs = dailyLogs + ) + } + + @Transactional(readOnly = true) + override fun getChallengeHistory(memberId: Long, page: Int, size: Int): Page { + val pageable = PageRequest.of(page, size) + return challengeRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId, pageable) + .map { ChallengeRespDto.fromEntity(it) } + } + + @Transactional + override fun cancelChallenge(memberId: Long, challengeId: Long) { + val challenge = challengeRepository.findById(challengeId) + .orElseThrow { ChallengeException(ChallengeErrorCode.CHALLENGE_NOT_FOUND) } + + if (challenge.memberId != memberId) { + throw ChallengeException(ChallengeErrorCode.CHALLENGE_ACCESS_DENIED) + } + + if (challenge.status != ChallengeStatus.ACTIVE) { + throw ChallengeException(ChallengeErrorCode.CHALLENGE_NOT_ACTIVE) + } + + challenge.status = ChallengeStatus.CANCELLED + challenge.updatedAt = LocalDateTime.now() + challengeRepository.save(challenge) + } + + @Transactional + override fun processDaily() { + val yesterday = LocalDate.now().minusDays(1) + val activeChallenges = challengeRepository.findAllByStatus(ChallengeStatus.ACTIVE) + + for (challenge in activeChallenges) { + try { + processSingleChallenge(challenge, yesterday) + } catch (e: Exception) { + log.error("챌린지 일일 처리 실패 - challengeId: ${challenge.id}, memberId: ${challenge.memberId}", e) + } + } + } + + private fun processSingleChallenge(challenge: Challenge, date: LocalDate) { + // Only process if date is within challenge period + if (date.isBefore(challenge.startDate) || date.isAfter(challenge.endDate)) { + // If past end date, check completion + if (date.isAfter(challenge.endDate)) { + if (challenge.achievedDays >= challenge.targetDays) { + challenge.status = ChallengeStatus.COMPLETED + } else { + challenge.status = ChallengeStatus.FAILED + } + challenge.updatedAt = LocalDateTime.now() + challengeRepository.save(challenge) + } + return + } + + // Check if already logged for this date + val existingLog = challengeDailyLogRepository.findByChallengeIdAndLogDate(challenge.id!!, date) + if (existingLog != null) return + + // Check OUTCOME transactions for this member on this date + val outcomeResults = transactionRepository.sumByMemberIdAndDateRange( + challenge.memberId, date, date + ) + + var outcomeAmount = 0 + for (row in outcomeResults) { + val txType = row[0].toString() + if (txType == "OUTCOME") { + outcomeAmount = (row[1] as Number).toInt() + break + } + } + + val isNoSpend = outcomeAmount == 0 + + // Create daily log + val dailyLog = ChallengeDailyLog( + challengeId = challenge.id!!, + logDate = date, + isNoSpend = isNoSpend, + outcomeAmount = outcomeAmount + ) + challengeDailyLogRepository.save(dailyLog) + + // Update achieved days if no-spend + if (isNoSpend) { + challenge.achievedDays += 1 + } + + // Check if challenge is completed (achieved target) + if (challenge.achievedDays >= challenge.targetDays) { + challenge.status = ChallengeStatus.COMPLETED + } + + challenge.updatedAt = LocalDateTime.now() + challengeRepository.save(challenge) + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/api/ItemApiController.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/api/ItemApiController.kt new file mode 100644 index 00000000..28228406 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/api/ItemApiController.kt @@ -0,0 +1,61 @@ +package com.side.tiggle.domain.item.api + +import com.side.tiggle.domain.item.dto.req.EquipItemReqDto +import com.side.tiggle.domain.item.dto.resp.EquipmentRespDto +import com.side.tiggle.domain.item.dto.resp.ItemCatalogRespDto +import com.side.tiggle.domain.item.dto.resp.MemberItemRespDto +import com.side.tiggle.domain.item.service.ItemService +import com.side.tiggle.global.common.ApiResponse +import com.side.tiggle.global.common.constants.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* + +@Validated +@RestController +@RequestMapping("/api/v1/items") +class ItemApiController( + private val itemService: ItemService +) { + + @GetMapping("/inventory") + fun getInventory( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long + ): ResponseEntity>> { + val inventory = itemService.getInventory(memberId) + return ResponseEntity.ok(ApiResponse.success(inventory)) + } + + @GetMapping("/catalog") + fun getCatalog( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long + ): ResponseEntity>> { + val catalog = itemService.getCatalog(memberId) + return ResponseEntity.ok(ApiResponse.success(catalog)) + } + + @PutMapping("/equip") + fun equipItem( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long, + @RequestBody request: EquipItemReqDto + ): ResponseEntity> { + itemService.equipItem(memberId, request.slot, request.itemId) + return ResponseEntity.ok(ApiResponse.success(null)) + } + + @GetMapping("/equipment") + fun getMyEquipment( + @RequestHeader(name = HttpHeaders.MEMBER_ID) memberId: Long + ): ResponseEntity> { + val equipment = itemService.getEquipment(memberId) + return ResponseEntity.ok(ApiResponse.success(equipment)) + } + + @GetMapping("/equipment/{memberId}") + fun getEquipment( + @PathVariable memberId: Long + ): ResponseEntity> { + val equipment = itemService.getEquipment(memberId) + return ResponseEntity.ok(ApiResponse.success(equipment)) + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/req/EquipItemReqDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/req/EquipItemReqDto.kt new file mode 100644 index 00000000..c069ba6f --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/req/EquipItemReqDto.kt @@ -0,0 +1,8 @@ +package com.side.tiggle.domain.item.dto.req + +import com.side.tiggle.domain.item.model.ItemSlot + +data class EquipItemReqDto( + val slot: ItemSlot, + val itemId: Long? +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/EquipmentRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/EquipmentRespDto.kt new file mode 100644 index 00000000..e49eb705 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/EquipmentRespDto.kt @@ -0,0 +1,10 @@ +package com.side.tiggle.domain.item.dto.resp + +data class EquipmentRespDto( + val hatItemId: Long?, + val outfitItemId: Long?, + val accessoryItemId: Long?, + val backgroundItemId: Long?, + val effectItemId: Long?, + val titleItemId: Long? +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/ItemCatalogRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/ItemCatalogRespDto.kt new file mode 100644 index 00000000..76a7e69d --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/ItemCatalogRespDto.kt @@ -0,0 +1,16 @@ +package com.side.tiggle.domain.item.dto.resp + +import com.side.tiggle.domain.item.model.ItemSlot +import com.side.tiggle.domain.item.model.ItemTier + +data class ItemCatalogRespDto( + val id: Long, + val name: String, + val nameEn: String, + val description: String?, + val slot: ItemSlot, + val tier: ItemTier, + val imageKey: String, + val requiredCharacterLevel: Int, + val unlocked: Boolean +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/MemberItemRespDto.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/MemberItemRespDto.kt new file mode 100644 index 00000000..3e852a83 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/dto/resp/MemberItemRespDto.kt @@ -0,0 +1,15 @@ +package com.side.tiggle.domain.item.dto.resp + +import com.side.tiggle.domain.item.model.ItemSlot +import com.side.tiggle.domain.item.model.ItemTier +import java.time.LocalDateTime + +data class MemberItemRespDto( + val itemId: Long, + val name: String, + val nameEn: String, + val slot: ItemSlot, + val tier: ItemTier, + val imageKey: String, + val acquiredAt: LocalDateTime +) diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/ItemException.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/ItemException.kt new file mode 100644 index 00000000..300ec5a3 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/ItemException.kt @@ -0,0 +1,19 @@ +package com.side.tiggle.domain.item.exception + +import com.side.tiggle.global.exception.CustomException +import com.side.tiggle.global.exception.error.ErrorCode + +class ItemException : CustomException { + + private val errorCode: ErrorCode + + constructor(errorCode: ErrorCode) : super(errorCode) { + this.errorCode = errorCode + } + + constructor(errorCode: ErrorCode, cause: Throwable) : super(errorCode, cause) { + this.errorCode = errorCode + } + + override fun getErrorCode(): ErrorCode = errorCode +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/error/ItemErrorCode.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/error/ItemErrorCode.kt new file mode 100644 index 00000000..e415622e --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/exception/error/ItemErrorCode.kt @@ -0,0 +1,27 @@ +package com.side.tiggle.domain.item.exception.error + +import com.side.tiggle.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +/** + * 아이템(Item) 도메인 에러 코드 정의 + * + * DD=85 (Item 도메인) + */ +enum class ItemErrorCode( + private val status: HttpStatus, + private val code: Int, + private val msg: String +) : ErrorCode { + // 조회 관련 오류 (85001~85010) + ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, 85001, "아이템을 찾을 수 없습니다"), + + // 장착 관련 오류 (85011~85020) + ITEM_NOT_OWNED(HttpStatus.BAD_REQUEST, 85011, "보유하지 않은 아이템입니다"), + ITEM_SLOT_MISMATCH(HttpStatus.BAD_REQUEST, 85012, "아이템 슬롯이 일치하지 않습니다"), + ITEM_ALREADY_OWNED(HttpStatus.CONFLICT, 85013, "이미 보유한 아이템입니다"); + + override fun httpStatus(): HttpStatus = status + override fun codeNumber(): Int = code + override fun message(): String = msg +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemCatalog.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemCatalog.kt new file mode 100644 index 00000000..814d5bf1 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemCatalog.kt @@ -0,0 +1,41 @@ +package com.side.tiggle.domain.item.model + +import jakarta.persistence.* + +@Entity +@Table(name = "item_catalog") +class ItemCatalog( + @Column(nullable = false, length = 100) + val name: String, + + @Column(name = "name_en", nullable = false, length = 100) + val nameEn: String, + + @Column(length = 500) + val description: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + val slot: ItemSlot, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + val tier: ItemTier, + + @Column(name = "image_key", nullable = false, length = 200) + val imageKey: String, + + @Enumerated(EnumType.STRING) + @Column(name = "unlock_type", nullable = false, length = 20) + val unlockType: UnlockType, + + @Column(name = "unlock_condition", length = 200) + val unlockCondition: String? = null, + + @Column(name = "required_character_level", nullable = false) + val requiredCharacterLevel: Int = 0 +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemSlot.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemSlot.kt new file mode 100644 index 00000000..6166d4fb --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemSlot.kt @@ -0,0 +1,5 @@ +package com.side.tiggle.domain.item.model + +enum class ItemSlot { + HAT, OUTFIT, ACCESSORY, BACKGROUND, EFFECT, TITLE +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemTier.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemTier.kt new file mode 100644 index 00000000..7f0b511a --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/ItemTier.kt @@ -0,0 +1,5 @@ +package com.side.tiggle.domain.item.model + +enum class ItemTier { + COMMON, RARE, EPIC, LEGENDARY, UNIQUE +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberEquipment.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberEquipment.kt new file mode 100644 index 00000000..718ca858 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberEquipment.kt @@ -0,0 +1,36 @@ +package com.side.tiggle.domain.item.model + +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table(name = "member_equipment") +class MemberEquipment( + @Column(name = "member_id", nullable = false, unique = true) + val memberId: Long, + + @Column(name = "hat_item_id") + var hatItemId: Long? = null, + + @Column(name = "outfit_item_id") + var outfitItemId: Long? = null, + + @Column(name = "accessory_item_id") + var accessoryItemId: Long? = null, + + @Column(name = "background_item_id") + var backgroundItemId: Long? = null, + + @Column(name = "effect_item_id") + var effectItemId: Long? = null, + + @Column(name = "title_item_id") + var titleItemId: Long? = null, + + @Column(name = "updated_at") + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberItem.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberItem.kt new file mode 100644 index 00000000..9fb84009 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/MemberItem.kt @@ -0,0 +1,27 @@ +package com.side.tiggle.domain.item.model + +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table( + name = "member_items", + uniqueConstraints = [UniqueConstraint(columnNames = ["member_id", "item_id"])] +) +class MemberItem( + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Column(name = "item_id", nullable = false) + val itemId: Long, + + @Column(name = "acquired_at", nullable = false) + val acquiredAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "acquire_reason", length = 200) + val acquireReason: String? = null +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/UnlockType.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/UnlockType.kt new file mode 100644 index 00000000..8a0e72fa --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/model/UnlockType.kt @@ -0,0 +1,5 @@ +package com.side.tiggle.domain.item.model + +enum class UnlockType { + ACHIEVEMENT, LEVEL, SPECIAL +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/ItemCatalogRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/ItemCatalogRepository.kt new file mode 100644 index 00000000..5f1c2c9c --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/ItemCatalogRepository.kt @@ -0,0 +1,11 @@ +package com.side.tiggle.domain.item.repository + +import com.side.tiggle.domain.item.model.ItemCatalog +import com.side.tiggle.domain.item.model.ItemSlot +import com.side.tiggle.domain.item.model.ItemTier +import org.springframework.data.jpa.repository.JpaRepository + +interface ItemCatalogRepository : JpaRepository { + fun findBySlot(slot: ItemSlot): List + fun findByTier(tier: ItemTier): List +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberEquipmentRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberEquipmentRepository.kt new file mode 100644 index 00000000..dbba53bb --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberEquipmentRepository.kt @@ -0,0 +1,8 @@ +package com.side.tiggle.domain.item.repository + +import com.side.tiggle.domain.item.model.MemberEquipment +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberEquipmentRepository : JpaRepository { + fun findByMemberId(memberId: Long): MemberEquipment? +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberItemRepository.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberItemRepository.kt new file mode 100644 index 00000000..02c824ce --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/repository/MemberItemRepository.kt @@ -0,0 +1,9 @@ +package com.side.tiggle.domain.item.repository + +import com.side.tiggle.domain.item.model.MemberItem +import org.springframework.data.jpa.repository.JpaRepository + +interface MemberItemRepository : JpaRepository { + fun findAllByMemberId(memberId: Long): List + fun existsByMemberIdAndItemId(memberId: Long, itemId: Long): Boolean +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemService.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemService.kt new file mode 100644 index 00000000..befc432f --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemService.kt @@ -0,0 +1,19 @@ +package com.side.tiggle.domain.item.service + +import com.side.tiggle.domain.item.dto.resp.EquipmentRespDto +import com.side.tiggle.domain.item.dto.resp.ItemCatalogRespDto +import com.side.tiggle.domain.item.dto.resp.MemberItemRespDto +import com.side.tiggle.domain.item.model.ItemSlot + +interface ItemService { + + fun getInventory(memberId: Long): List + + fun getCatalog(memberId: Long): List + + fun equipItem(memberId: Long, slot: ItemSlot, itemId: Long?) + + fun getEquipment(memberId: Long): EquipmentRespDto + + fun grantItem(memberId: Long, itemId: Long, reason: String) +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemServiceImpl.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemServiceImpl.kt new file mode 100644 index 00000000..f4d8d313 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/item/service/ItemServiceImpl.kt @@ -0,0 +1,138 @@ +package com.side.tiggle.domain.item.service + +import com.side.tiggle.domain.item.dto.resp.EquipmentRespDto +import com.side.tiggle.domain.item.dto.resp.ItemCatalogRespDto +import com.side.tiggle.domain.item.dto.resp.MemberItemRespDto +import com.side.tiggle.domain.item.exception.ItemException +import com.side.tiggle.domain.item.exception.error.ItemErrorCode +import com.side.tiggle.domain.item.model.ItemSlot +import com.side.tiggle.domain.item.model.MemberEquipment +import com.side.tiggle.domain.item.model.MemberItem +import com.side.tiggle.domain.item.repository.ItemCatalogRepository +import com.side.tiggle.domain.item.repository.MemberEquipmentRepository +import com.side.tiggle.domain.item.repository.MemberItemRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class ItemServiceImpl( + private val itemCatalogRepository: ItemCatalogRepository, + private val memberItemRepository: MemberItemRepository, + private val memberEquipmentRepository: MemberEquipmentRepository +) : ItemService { + + override fun getInventory(memberId: Long): List { + val memberItems = memberItemRepository.findAllByMemberId(memberId) + val itemIds = memberItems.map { it.itemId } + val catalogMap = itemCatalogRepository.findAllById(itemIds).associateBy { it.id } + + return memberItems.mapNotNull { mi -> + val catalog = catalogMap[mi.itemId] ?: return@mapNotNull null + MemberItemRespDto( + itemId = catalog.id!!, + name = catalog.name, + nameEn = catalog.nameEn, + slot = catalog.slot, + tier = catalog.tier, + imageKey = catalog.imageKey, + acquiredAt = mi.acquiredAt + ) + } + } + + override fun getCatalog(memberId: Long): List { + val allItems = itemCatalogRepository.findAll() + val ownedItemIds = memberItemRepository.findAllByMemberId(memberId) + .map { it.itemId } + .toSet() + + return allItems.map { item -> + ItemCatalogRespDto( + id = item.id!!, + name = item.name, + nameEn = item.nameEn, + description = item.description, + slot = item.slot, + tier = item.tier, + imageKey = item.imageKey, + requiredCharacterLevel = item.requiredCharacterLevel, + unlocked = item.id!! in ownedItemIds + ) + } + } + + @Transactional + override fun equipItem(memberId: Long, slot: ItemSlot, itemId: Long?) { + val equipment = memberEquipmentRepository.findByMemberId(memberId) + ?: memberEquipmentRepository.save(MemberEquipment(memberId = memberId)) + + // If unequipping (itemId is null), just clear the slot + if (itemId == null) { + setSlot(equipment, slot, null) + equipment.updatedAt = LocalDateTime.now() + memberEquipmentRepository.save(equipment) + return + } + + // Check item exists + val catalog = itemCatalogRepository.findById(itemId) + .orElseThrow { ItemException(ItemErrorCode.ITEM_NOT_FOUND) } + + // Check slot matches + if (catalog.slot != slot) { + throw ItemException(ItemErrorCode.ITEM_SLOT_MISMATCH) + } + + // Check member owns the item + if (!memberItemRepository.existsByMemberIdAndItemId(memberId, itemId)) { + throw ItemException(ItemErrorCode.ITEM_NOT_OWNED) + } + + setSlot(equipment, slot, itemId) + equipment.updatedAt = LocalDateTime.now() + memberEquipmentRepository.save(equipment) + } + + override fun getEquipment(memberId: Long): EquipmentRespDto { + val equipment = memberEquipmentRepository.findByMemberId(memberId) + return EquipmentRespDto( + hatItemId = equipment?.hatItemId, + outfitItemId = equipment?.outfitItemId, + accessoryItemId = equipment?.accessoryItemId, + backgroundItemId = equipment?.backgroundItemId, + effectItemId = equipment?.effectItemId, + titleItemId = equipment?.titleItemId + ) + } + + @Transactional + override fun grantItem(memberId: Long, itemId: Long, reason: String) { + if (memberItemRepository.existsByMemberIdAndItemId(memberId, itemId)) { + return // Already owned, silently ignore + } + + // Verify item exists in catalog + if (!itemCatalogRepository.existsById(itemId)) { + throw ItemException(ItemErrorCode.ITEM_NOT_FOUND) + } + + val memberItem = MemberItem( + memberId = memberId, + itemId = itemId, + acquireReason = reason + ) + memberItemRepository.save(memberItem) + } + + private fun setSlot(equipment: MemberEquipment, slot: ItemSlot, itemId: Long?) { + when (slot) { + ItemSlot.HAT -> equipment.hatItemId = itemId + ItemSlot.OUTFIT -> equipment.outfitItemId = itemId + ItemSlot.ACCESSORY -> equipment.accessoryItemId = itemId + ItemSlot.BACKGROUND -> equipment.backgroundItemId = itemId + ItemSlot.EFFECT -> equipment.effectItemId = itemId + ItemSlot.TITLE -> equipment.titleItemId = itemId + } + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/statistics/scheduler/StatisticsScheduler.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/statistics/scheduler/StatisticsScheduler.kt new file mode 100644 index 00000000..9f2fd921 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/statistics/scheduler/StatisticsScheduler.kt @@ -0,0 +1,42 @@ +package com.side.tiggle.domain.statistics.scheduler + +import com.side.tiggle.domain.member.repository.MemberRepository +import com.side.tiggle.domain.statistics.service.StatisticsService +import com.side.tiggle.global.common.logging.log +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate + +@Component +class StatisticsScheduler( + private val statisticsService: StatisticsService, + private val memberRepository: MemberRepository +) { + + /** + * 매주 월요일 새벽 1시에 지난 주 통계 스냅샷 생성 + */ + @Scheduled(cron = "0 0 1 * * MON") + fun generateWeeklySnapshots() { + val lastMonday = LocalDate.now().minusWeeks(1).with(DayOfWeek.MONDAY) + val members = memberRepository.findAll() + + log.info("주간 통계 스냅샷 생성 시작 - 대상 주: $lastMonday, 회원 수: ${members.size}") + + var successCount = 0 + var failCount = 0 + + members.forEach { member -> + try { + statisticsService.generateWeeklySnapshot(member.id, lastMonday) + successCount++ + } catch (e: Exception) { + failCount++ + log.error("주간 통계 스냅샷 생성 실패 - memberId: ${member.id}", e) + } + } + + log.info("주간 통계 스냅샷 생성 완료 - 성공: $successCount, 실패: $failCount") + } +} diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImpl.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImpl.kt index 93694d92..de4116f8 100644 --- a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImpl.kt +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImpl.kt @@ -2,6 +2,7 @@ package com.side.tiggle.domain.transaction.service import com.fasterxml.jackson.databind.ObjectMapper import com.side.tiggle.domain.category.service.CategoryService +import com.side.tiggle.domain.character.service.CharacterService import com.side.tiggle.domain.member.service.MemberService import com.side.tiggle.domain.transaction.dto.internal.TransactionInfo import com.side.tiggle.domain.transaction.dto.req.TransactionCreateReqDto @@ -35,7 +36,8 @@ class TransactionServiceImpl( private val categoryService: CategoryService, private val transactionMapper: TransactionMapper, private val transactionFileUploadUtil: TransactionFileUploadUtil, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val characterService: CharacterService ) : TransactionService { @Transactional @@ -58,6 +60,14 @@ class TransactionServiceImpl( transactionRepository.save( dto.toEntity(member, category) ) + + // 캐릭터 경험치 연동 + try { + characterService.incrementEggRecords(memberId) + characterService.addExperience(memberId, 10, "TRANSACTION_RECORD") + } catch (charEx: Exception) { + log.warn("캐릭터 업데이트 실패 - memberId: $memberId", charEx) + } } catch (e: Exception) { savedPaths?.forEach { Files.deleteIfExists(it) } transactionFileUploadUtil.deleteEmptyDateFolder() diff --git a/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/global/config/SchedulerConfig.kt b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/global/config/SchedulerConfig.kt new file mode 100644 index 00000000..31ff34f4 --- /dev/null +++ b/backend/tiggle-root/tiggle/src/main/kotlin/com/side/tiggle/global/config/SchedulerConfig.kt @@ -0,0 +1,8 @@ +package com.side.tiggle.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +@Configuration +@EnableScheduling +class SchedulerConfig diff --git a/backend/tiggle-root/tiggle/src/test/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImplTest.kt b/backend/tiggle-root/tiggle/src/test/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImplTest.kt index 0492a0d8..0bef9b1d 100644 --- a/backend/tiggle-root/tiggle/src/test/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImplTest.kt +++ b/backend/tiggle-root/tiggle/src/test/kotlin/com/side/tiggle/domain/transaction/service/TransactionServiceImplTest.kt @@ -3,6 +3,7 @@ package com.side.tiggle.domain.transaction.service import com.fasterxml.jackson.databind.ObjectMapper import com.side.tiggle.domain.category.model.Category import com.side.tiggle.domain.category.service.CategoryService +import com.side.tiggle.domain.character.service.CharacterService import com.side.tiggle.domain.member.service.MemberService import com.side.tiggle.domain.transaction.dto.internal.TransactionInfo import com.side.tiggle.domain.transaction.dto.req.TransactionCreateReqDto @@ -39,6 +40,7 @@ class TransactionServiceImplTest : StringSpec({ val transactionMapper: TransactionMapper = mockk() val transactionFileUploadUtil: TransactionFileUploadUtil = mockk() val objectMapper: ObjectMapper = mockk() + val characterService: CharacterService = mockk(relaxed = true) val transactionService: TransactionService = TransactionServiceImpl( transactionRepository, @@ -46,7 +48,8 @@ class TransactionServiceImplTest : StringSpec({ categoryService, transactionMapper, transactionFileUploadUtil, - objectMapper + objectMapper, + characterService ) beforeEach {