feat(admin): 구독/결제 관리 API
CI / build (push) Failing after 15m7s

- GET /api/admin/subscriptions (filter=active|inactive|all) + /summary(활성·만료임박·티어/플랫폼 분포)
- POST /grant: 관리자 수동 부여(회원 tier 동기화, platform=ADMIN), PUT /{id}/extend, /{id}/cancel(tier FREE 환원)
- V6: subscriptions.platform CHECK 에 ADMIN 허용
- Subscription 엔티티 확장 + 리포지토리, StatsSeedData 구독 더미(활성/만료/임박)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-11 12:40:32 +09:00
parent 2061974695
commit 6da7339626
10 changed files with 340 additions and 2 deletions
@@ -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<SubscriptionAdminResponse> 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);
}
}
@@ -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<String> 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<SubscriptionAdminResponse> 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<SubscriptionSummary.Count> byTier = counts(
"SELECT tier, COUNT(*) FROM subscriptions WHERE is_active = TRUE GROUP BY tier");
List<SubscriptionSummary.Count> 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<SubscriptionSummary.Count> counts(String sql) {
return jdbc.query(sql, (rs, i) -> new SubscriptionSummary.Count(rs.getString(1), rs.getLong(2)));
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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) {
}
@@ -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<Count> byTier,
List<Count> byPlatform) {
public record Count(String key, long count) {
}
}
@@ -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)
@@ -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;
}
}
@@ -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<Subscription, Long> {
/** 회원의 현재 활성 구독들 (수동 부여/취소 시 정리용). */
List<Subscription> findByUserIdAndActiveTrue(Long userId);
}
@@ -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'));