diff --git a/src/main/java/com/dog/dognation/admin/AdminSubscriptionController.java b/src/main/java/com/dog/dognation/admin/AdminSubscriptionController.java new file mode 100644 index 0000000..6c72459 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminSubscriptionController.java @@ -0,0 +1,58 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.ExtendRequest; +import com.dog.dognation.admin.dto.GrantRequest; +import com.dog.dognation.admin.dto.SubscriptionAdminResponse; +import com.dog.dognation.admin.dto.SubscriptionSummary; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 관리자 구독/결제 관리 API. (/api/admin/subscriptions — ADMIN 전용) + */ +@RestController +@RequestMapping("/api/admin/subscriptions") +public class AdminSubscriptionController { + + private final AdminSubscriptionService subscriptionService; + + public AdminSubscriptionController(AdminSubscriptionService subscriptionService) { + this.subscriptionService = subscriptionService; + } + + /** 구독 목록. filter=active|inactive|all. */ + @GetMapping + public List list(@RequestParam(defaultValue = "all") String filter) { + return subscriptionService.list(filter); + } + + @GetMapping("/summary") + public SubscriptionSummary summary() { + return subscriptionService.summary(); + } + + /** 관리자 수동 부여. */ + @PostMapping("/grant") + public SubscriptionAdminResponse grant(@Valid @RequestBody GrantRequest req) { + return subscriptionService.grant(req.userId(), req.tier(), req.months()); + } + + @PutMapping("/{id}/extend") + public SubscriptionAdminResponse extend(@PathVariable Long id, @Valid @RequestBody ExtendRequest req) { + return subscriptionService.extend(id, req.months()); + } + + @PutMapping("/{id}/cancel") + public SubscriptionAdminResponse cancel(@PathVariable Long id) { + return subscriptionService.cancel(id); + } +} diff --git a/src/main/java/com/dog/dognation/admin/AdminSubscriptionService.java b/src/main/java/com/dog/dognation/admin/AdminSubscriptionService.java new file mode 100644 index 0000000..a93e01e --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/AdminSubscriptionService.java @@ -0,0 +1,144 @@ +package com.dog.dognation.admin; + +import com.dog.dognation.admin.dto.SubscriptionAdminResponse; +import com.dog.dognation.admin.dto.SubscriptionSummary; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.billing.Subscription; +import com.dog.dognation.domain.billing.SubscriptionRepository; +import com.dog.dognation.domain.user.SubscriptionTier; +import com.dog.dognation.domain.user.User; +import com.dog.dognation.domain.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * 관리자 구독/결제 관리. + * 실제 IAP(영수증 검증)는 미연동. 여기서는 조회 + 관리자 수동 부여/연장/취소를 제공하며, + * 회원의 subscription_tier 를 함께 동기화한다. + */ +@Service +public class AdminSubscriptionService { + + private static final Set GRANTABLE = Set.of("AD_FREE", "PREMIUM"); + + private final SubscriptionRepository subscriptionRepository; + private final UserRepository userRepository; + private final JdbcTemplate jdbc; + + public AdminSubscriptionService(SubscriptionRepository subscriptionRepository, + UserRepository userRepository, + JdbcTemplate jdbc) { + this.subscriptionRepository = subscriptionRepository; + this.userRepository = userRepository; + this.jdbc = jdbc; + } + + /** 구독 목록. filter = active / inactive / all(기본). */ + @Transactional(readOnly = true) + public List list(String filter) { + String where = switch (filter == null ? "all" : filter) { + case "active" -> " WHERE s.is_active = TRUE"; + case "inactive" -> " WHERE s.is_active = FALSE"; + default -> ""; + }; + return jdbc.query(""" + SELECT s.id, s.user_id, u.email, u.nickname, s.tier, s.platform, + s.started_at, s.expires_at, s.is_active, s.created_at + FROM subscriptions s JOIN users u ON s.user_id = u.id + """ + where + " ORDER BY s.created_at DESC", + (rs, i) -> new SubscriptionAdminResponse( + rs.getLong("id"), rs.getLong("user_id"), rs.getString("email"), + rs.getString("nickname"), rs.getString("tier"), rs.getString("platform"), + rs.getObject("started_at", OffsetDateTime.class), + rs.getObject("expires_at", OffsetDateTime.class), + rs.getBoolean("is_active"), + rs.getObject("created_at", OffsetDateTime.class))); + } + + @Transactional(readOnly = true) + public SubscriptionSummary summary() { + long active = count("SELECT COUNT(*) FROM subscriptions WHERE is_active = TRUE"); + OffsetDateTime now = OffsetDateTime.now(); + Long soon = jdbc.queryForObject( + "SELECT COUNT(*) FROM subscriptions WHERE is_active = TRUE AND expires_at >= ? AND expires_at < ?", + Long.class, now, now.plusDays(7)); + List byTier = counts( + "SELECT tier, COUNT(*) FROM subscriptions WHERE is_active = TRUE GROUP BY tier"); + List byPlatform = counts( + "SELECT platform, COUNT(*) FROM subscriptions WHERE is_active = TRUE GROUP BY platform"); + return new SubscriptionSummary(active, soon == null ? 0 : soon, byTier, byPlatform); + } + + /** 관리자 수동 부여 — 기존 활성 구독은 정리하고 새로 부여, 회원 티어 동기화. */ + @Transactional + public SubscriptionAdminResponse grant(Long userId, String tier, int months) { + if (!GRANTABLE.contains(tier)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "부여 가능한 티어는 AD_FREE 또는 PREMIUM 입니다."); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + subscriptionRepository.findByUserIdAndActiveTrue(userId).forEach(Subscription::deactivate); + + OffsetDateTime now = OffsetDateTime.now(); + Subscription sub = subscriptionRepository.save(new Subscription( + userId, tier, "ADMIN", "ADMIN-" + UUID.randomUUID().toString().substring(0, 12), + now, now.plusMonths(months))); + user.setSubscriptionTier(SubscriptionTier.valueOf(tier)); + return toResponse(sub, user); + } + + /** 구독 연장 — 현재 만료일(또는 지금)부터 months 개월 연장, 회원 티어 재동기화. */ + @Transactional + public SubscriptionAdminResponse extend(Long id, int months) { + if (months < 1) { + throw new ApiException(HttpStatus.BAD_REQUEST, "개월 수는 1 이상이어야 합니다."); + } + Subscription sub = mustFind(id); + OffsetDateTime base = sub.getExpiresAt().isAfter(OffsetDateTime.now()) + ? sub.getExpiresAt() : OffsetDateTime.now(); + sub.extendTo(base.plusMonths(months)); + User user = userRepository.findById(sub.getUserId()) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + user.setSubscriptionTier(SubscriptionTier.valueOf(sub.getTier())); + return toResponse(sub, user); + } + + /** 구독 취소 — 비활성화 + 회원 티어 FREE 로 환원. */ + @Transactional + public SubscriptionAdminResponse cancel(Long id) { + Subscription sub = mustFind(id); + sub.deactivate(); + User user = userRepository.findById(sub.getUserId()) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.")); + user.setSubscriptionTier(SubscriptionTier.FREE); + return toResponse(sub, user); + } + + private Subscription mustFind(Long id) { + return subscriptionRepository.findById(id) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "구독을 찾을 수 없습니다.")); + } + + private SubscriptionAdminResponse toResponse(Subscription s, User u) { + return new SubscriptionAdminResponse( + s.getId(), s.getUserId(), u.getEmail(), u.getNickname(), s.getTier(), s.getPlatform(), + s.getStartedAt(), s.getExpiresAt(), s.isActive(), s.getCreatedAt()); + } + + private long count(String sql) { + Long n = jdbc.queryForObject(sql, Long.class); + return n == null ? 0 : n; + } + + private List counts(String sql) { + return jdbc.query(sql, (rs, i) -> new SubscriptionSummary.Count(rs.getString(1), rs.getLong(2))); + } +} diff --git a/src/main/java/com/dog/dognation/admin/dto/ExtendRequest.java b/src/main/java/com/dog/dognation/admin/dto/ExtendRequest.java new file mode 100644 index 0000000..10f20a7 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/ExtendRequest.java @@ -0,0 +1,7 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.Min; + +/** 구독 연장 — months 개월. */ +public record ExtendRequest(@Min(value = 1, message = "개월 수는 1 이상이어야 합니다.") int months) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/GrantRequest.java b/src/main/java/com/dog/dognation/admin/dto/GrantRequest.java new file mode 100644 index 0000000..704d994 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/GrantRequest.java @@ -0,0 +1,12 @@ +package com.dog.dognation.admin.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** 관리자 수동 구독 부여 — 회원에게 tier 를 months 개월 부여. */ +public record GrantRequest( + @NotNull(message = "회원을 선택하세요.") Long userId, + @NotBlank(message = "구독 티어를 입력하세요.") String tier, + @Min(value = 1, message = "개월 수는 1 이상이어야 합니다.") int months) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/SubscriptionAdminResponse.java b/src/main/java/com/dog/dognation/admin/dto/SubscriptionAdminResponse.java new file mode 100644 index 0000000..307da9f --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/SubscriptionAdminResponse.java @@ -0,0 +1,17 @@ +package com.dog.dognation.admin.dto; + +import java.time.OffsetDateTime; + +/** 관리자 구독 목록/상세 행 — 구독 + 회원 정보. */ +public record SubscriptionAdminResponse( + Long id, + Long userId, + String email, + String nickname, + String tier, + String platform, + OffsetDateTime startedAt, + OffsetDateTime expiresAt, + boolean active, + OffsetDateTime createdAt) { +} diff --git a/src/main/java/com/dog/dognation/admin/dto/SubscriptionSummary.java b/src/main/java/com/dog/dognation/admin/dto/SubscriptionSummary.java new file mode 100644 index 0000000..3e5cfb5 --- /dev/null +++ b/src/main/java/com/dog/dognation/admin/dto/SubscriptionSummary.java @@ -0,0 +1,19 @@ +package com.dog.dognation.admin.dto; + +import java.util.List; + +/** + * 구독 관리 요약. + * - active: 현재 활성 구독 수 + * - expiringSoon: 7일 내 만료 예정(활성) + * - byTier/byPlatform: 활성 구독 분포 + */ +public record SubscriptionSummary( + long active, + long expiringSoon, + List byTier, + List byPlatform) { + + public record Count(String key, long count) { + } +} diff --git a/src/main/java/com/dog/dognation/config/StatsSeedData.java b/src/main/java/com/dog/dognation/config/StatsSeedData.java index 88ba53d..fe7d7a4 100644 --- a/src/main/java/com/dog/dognation/config/StatsSeedData.java +++ b/src/main/java/com/dog/dognation/config/StatsSeedData.java @@ -64,6 +64,12 @@ public class StatsSeedData implements CommandLineRunner { } } + // 구독 (활성/만료/임박 혼합) — visitor2(AD_FREE), visitor3·6(PREMIUM) + insertSubscription(users.get(1), "AD_FREE", "GOOGLE", now.minusMonths(1), now.plusMonths(11), true); + insertSubscription(users.get(2), "PREMIUM", "APPLE", now.minusMonths(2), now.plusMonths(10), true); + insertSubscription(users.get(5), "PREMIUM", "GOOGLE", now.minusMonths(11), now.plusDays(5), true); // 만료 임박 + insertSubscription(users.get(0), "PREMIUM", "APPLE", now.minusMonths(13), now.minusMonths(1), false); // 만료됨 + // 탈퇴 로그 — 금일 2건 + 최근 며칠 3건 insertWithdrawal("left1@dog.com", "FREE", now); insertWithdrawal("left2@dog.com", "PREMIUM", now); @@ -100,6 +106,14 @@ public class StatsSeedData implements CommandLineRunner { return jdbc.queryForObject("SELECT id FROM spots WHERE name = ?", Long.class, name); } + private void insertSubscription(long userId, String tier, String platform, + OffsetDateTime start, OffsetDateTime expires, boolean active) { + jdbc.update(""" + INSERT INTO subscriptions(user_id, tier, platform, original_txn_id, started_at, expires_at, is_active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, userId, tier, platform, "SEED-" + userId + "-" + tier, start, expires, active); + } + private void insertWithdrawal(String email, String tier, OffsetDateTime at) { jdbc.update(""" INSERT INTO withdrawal_log(user_id, email, tier, withdrawn_at) diff --git a/src/main/java/com/dog/dognation/domain/billing/Subscription.java b/src/main/java/com/dog/dognation/domain/billing/Subscription.java index ce7b6c5..c732d74 100644 --- a/src/main/java/com/dog/dognation/domain/billing/Subscription.java +++ b/src/main/java/com/dog/dognation/domain/billing/Subscription.java @@ -11,7 +11,7 @@ import java.time.OffsetDateTime; /** * 인앱결제(IAP) 구독 이력 — 애플/구글 영수증 기준. (subscriptions 테이블) - * 현재는 스키마/조회용 매핑만 제공한다. 실제 IAP 연동 시 확장. + * platform='ADMIN' 은 관리자 수동 부여. 실제 IAP 연동 시 확장. */ @Entity @Table(name = "subscriptions") @@ -28,7 +28,7 @@ public class Subscription { private String tier; // AD_FREE / PREMIUM @Column(nullable = false, length = 20) - private String platform; // APPLE / GOOGLE + private String platform; // APPLE / GOOGLE / ADMIN @Column(name = "original_txn_id", nullable = false) private String originalTxnId; @@ -48,7 +48,56 @@ public class Subscription { protected Subscription() { } + public Subscription(Long userId, String tier, String platform, String originalTxnId, + OffsetDateTime startedAt, OffsetDateTime expiresAt) { + this.userId = userId; + this.tier = tier; + this.platform = platform; + this.originalTxnId = originalTxnId; + this.startedAt = startedAt; + this.expiresAt = expiresAt; + this.active = true; + this.createdAt = OffsetDateTime.now(); + } + + public void deactivate() { + this.active = false; + } + + public void extendTo(OffsetDateTime expiresAt) { + this.expiresAt = expiresAt; + this.active = true; + } + public Long getId() { return id; } + + public Long getUserId() { + return userId; + } + + public String getTier() { + return tier; + } + + public String getPlatform() { + return platform; + } + + public OffsetDateTime getStartedAt() { + return startedAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public boolean isActive() { + return active; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } } diff --git a/src/main/java/com/dog/dognation/domain/billing/SubscriptionRepository.java b/src/main/java/com/dog/dognation/domain/billing/SubscriptionRepository.java new file mode 100644 index 0000000..6a3dbfe --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/billing/SubscriptionRepository.java @@ -0,0 +1,11 @@ +package com.dog.dognation.domain.billing; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SubscriptionRepository extends JpaRepository { + + /** 회원의 현재 활성 구독들 (수동 부여/취소 시 정리용). */ + List findByUserIdAndActiveTrue(Long userId); +} diff --git a/src/main/resources/db/migration/V6__subscription_admin.sql b/src/main/resources/db/migration/V6__subscription_admin.sql new file mode 100644 index 0000000..64758ba --- /dev/null +++ b/src/main/resources/db/migration/V6__subscription_admin.sql @@ -0,0 +1,7 @@ +-- ============================================================ +-- V6: 관리자 수동 구독 부여 지원 +-- platform 에 'ADMIN'(관리자 수동 부여) 허용. +-- ============================================================ +ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_platform_check; +ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_platform_check + CHECK (platform IN ('APPLE', 'GOOGLE', 'ADMIN'));