- 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:
@@ -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건
|
// 탈퇴 로그 — 금일 2건 + 최근 며칠 3건
|
||||||
insertWithdrawal("left1@dog.com", "FREE", now);
|
insertWithdrawal("left1@dog.com", "FREE", now);
|
||||||
insertWithdrawal("left2@dog.com", "PREMIUM", 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);
|
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) {
|
private void insertWithdrawal(String email, String tier, OffsetDateTime at) {
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
INSERT INTO withdrawal_log(user_id, email, tier, withdrawn_at)
|
INSERT INTO withdrawal_log(user_id, email, tier, withdrawn_at)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.time.OffsetDateTime;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 인앱결제(IAP) 구독 이력 — 애플/구글 영수증 기준. (subscriptions 테이블)
|
* 인앱결제(IAP) 구독 이력 — 애플/구글 영수증 기준. (subscriptions 테이블)
|
||||||
* 현재는 스키마/조회용 매핑만 제공한다. 실제 IAP 연동 시 확장.
|
* platform='ADMIN' 은 관리자 수동 부여. 실제 IAP 연동 시 확장.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "subscriptions")
|
@Table(name = "subscriptions")
|
||||||
@@ -28,7 +28,7 @@ public class Subscription {
|
|||||||
private String tier; // AD_FREE / PREMIUM
|
private String tier; // AD_FREE / PREMIUM
|
||||||
|
|
||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private String platform; // APPLE / GOOGLE
|
private String platform; // APPLE / GOOGLE / ADMIN
|
||||||
|
|
||||||
@Column(name = "original_txn_id", nullable = false)
|
@Column(name = "original_txn_id", nullable = false)
|
||||||
private String originalTxnId;
|
private String originalTxnId;
|
||||||
@@ -48,7 +48,56 @@ public class Subscription {
|
|||||||
protected 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() {
|
public Long getId() {
|
||||||
return id;
|
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'));
|
||||||
Reference in New Issue
Block a user