feat: 매칭 쿼터 자정 초기화 스케줄러 및 초기 DB 스키마

- PostgreSQL/PostGIS 초기 스키마(users/dogs/성향태그/스팟/체크인/한줄평/채팅/구독/매칭쿼터)
- 매일 자정(KST) 무료·광고제거 유저 매칭 3회 초기화 스케줄러(벌크 UPDATE, PREMIUM 제외)
- 매칭 소진/차단 서비스 및 통합 테스트(H2) 3건 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 07:36:49 +09:00
commit e53e4e12f2
18 changed files with 979 additions and 0 deletions
@@ -0,0 +1,14 @@
package com.dog.dognation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class DognationApplication {
public static void main(String[] args) {
SpringApplication.run(DognationApplication.class, args);
}
}
@@ -0,0 +1,64 @@
package com.dog.dognation.domain.matching;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.OffsetDateTime;
/**
* 사용자별 하루 AI 매칭 신청 쿼터.
* 매일 자정(KST) 스케줄러가 remaining_count 를 무료 한도(기본 3)로 초기화한다.
* PREMIUM 사용자는 무제한이므로 차감/초기화 대상에서 제외된다.
*/
@Entity
@Table(name = "daily_match_quota")
public class DailyMatchQuota {
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "remaining_count", nullable = false)
private int remainingCount;
@Column(name = "last_reset_date", nullable = false)
private LocalDate lastResetDate;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
protected DailyMatchQuota() {
}
public DailyMatchQuota(Long userId, int remainingCount, LocalDate lastResetDate) {
this.userId = userId;
this.remainingCount = remainingCount;
this.lastResetDate = lastResetDate;
this.updatedAt = OffsetDateTime.now();
}
/** 매칭 신청 1회 소진. 잔여가 없으면 false. */
public boolean tryConsume() {
if (remainingCount <= 0) {
return false;
}
remainingCount--;
updatedAt = OffsetDateTime.now();
return true;
}
public Long getUserId() {
return userId;
}
public int getRemainingCount() {
return remainingCount;
}
public LocalDate getLastResetDate() {
return lastResetDate;
}
}
@@ -0,0 +1,33 @@
package com.dog.dognation.domain.matching;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public interface DailyMatchQuotaRepository extends JpaRepository<DailyMatchQuota, Long> {
/**
* 무료/광고제거(비 PREMIUM) 사용자의 쿼터를 한 번의 벌크 UPDATE 로 초기화한다.
* 수백만 행이어도 엔티티를 로딩하지 않으므로 자정 배치에 적합하다.
*
* @return 초기화된(=영향받은) 행 수
*/
@Modifying(clearAutomatically = true)
@Query("""
update DailyMatchQuota q
set q.remainingCount = :limit,
q.lastResetDate = :today,
q.updatedAt = :now
where q.userId in (
select u.id from User u
where u.subscriptionTier <> com.dog.dognation.domain.user.SubscriptionTier.PREMIUM
)
""")
int resetQuotaForNonPremium(@Param("limit") int limit,
@Param("today") LocalDate today,
@Param("now") OffsetDateTime now);
}
@@ -0,0 +1,47 @@
package com.dog.dognation.domain.matching;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.OffsetDateTime;
/**
* 하루 매칭 쿼터 도메인 서비스.
* - 자정 배치 초기화(resetAll)
* - 매칭 신청 시 1회 소진(consumeOne)
*/
@Service
public class MatchQuotaService {
private final DailyMatchQuotaRepository quotaRepository;
private final int dailyFreeLimit;
public MatchQuotaService(DailyMatchQuotaRepository quotaRepository,
@Value("${app.matching.daily-free-limit:3}") int dailyFreeLimit) {
this.quotaRepository = quotaRepository;
this.dailyFreeLimit = dailyFreeLimit;
}
/**
* 비 PREMIUM 사용자 전원의 쿼터를 무료 한도로 초기화한다. (스케줄러 호출)
* @return 초기화된 행 수
*/
@Transactional
public int resetAll() {
return quotaRepository.resetQuotaForNonPremium(dailyFreeLimit, LocalDate.now(), OffsetDateTime.now());
}
/**
* 매칭 신청 1회 차감. 잔여가 없으면 false 반환(호출측에서 402/차단 처리).
* PREMIUM 은 애초에 이 메서드를 태우지 않고 무제한 허용하는 것을 전제로 한다.
*/
@Transactional
public boolean consumeOne(Long userId) {
DailyMatchQuota quota = quotaRepository.findById(userId)
.orElseGet(() -> quotaRepository.save(
new DailyMatchQuota(userId, dailyFreeLimit, LocalDate.now())));
return quota.tryConsume();
}
}
@@ -0,0 +1,18 @@
package com.dog.dognation.domain.user;
/**
* 구독 티어.
* FREE : 광고 노출 + 하루 매칭 3회
* AD_FREE : 광고 제거 + 하루 매칭 3회
* PREMIUM : 광고 제거 + 매칭 무제한 + 고급 필터/리포트
*/
public enum SubscriptionTier {
FREE,
AD_FREE,
PREMIUM;
/** 하루 매칭 쿼터 적용 대상 여부 (PREMIUM 은 무제한). */
public boolean isQuotaLimited() {
return this != PREMIUM;
}
}
@@ -0,0 +1,62 @@
package com.dog.dognation.domain.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(nullable = false, length = 50)
private String nickname;
@Enumerated(EnumType.STRING)
@Column(name = "subscription_tier", nullable = false, length = 20)
private SubscriptionTier subscriptionTier = SubscriptionTier.FREE;
@Column(nullable = false, length = 20)
private String status = "ACTIVE";
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now();
protected User() {
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getNickname() {
return nickname;
}
public SubscriptionTier getSubscriptionTier() {
return subscriptionTier;
}
}
@@ -0,0 +1,33 @@
package com.dog.dognation.scheduler;
import com.dog.dognation.domain.matching.MatchQuotaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 매일 밤 자정(KST)에 무료/광고제거 사용자의 AI 매칭 신청 횟수를 초기화한다.
*
* cron: 초 분 시 일 월 요일 -> "0 0 0 * * *" = 매일 00:00:00
* zone: Asia/Seoul (서버 타임존과 무관하게 한국 자정 기준 고정)
*/
@Component
public class MatchQuotaScheduler {
private static final Logger log = LoggerFactory.getLogger(MatchQuotaScheduler.class);
private final MatchQuotaService matchQuotaService;
public MatchQuotaScheduler(MatchQuotaService matchQuotaService) {
this.matchQuotaService = matchQuotaService;
}
@Scheduled(cron = "0 0 0 * * *", zone = "${app.scheduler.timezone:Asia/Seoul}")
public void resetDailyMatchQuota() {
long start = System.currentTimeMillis();
int updated = matchQuotaService.resetAll();
log.info("[매칭쿼터 초기화] 완료 - 대상 {}명, 소요 {}ms",
updated, System.currentTimeMillis() - start);
}
}