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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
spring:
|
||||
application:
|
||||
name: dognation
|
||||
|
||||
datasource:
|
||||
# DB 접속정보는 .env.dev / .env.prod 에서 주입 (POSTGRES_DB, SPRING_DATASOURCE_*)
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${POSTGRES_DB:dogdb_dev}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:dognation}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate # 스키마는 Flyway가 관리, JPA는 검증만
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
open-in-view: false
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
# 스케줄러/시간 기준: 한국 시간(KST) 자정
|
||||
app:
|
||||
scheduler:
|
||||
timezone: Asia/Seoul
|
||||
matching:
|
||||
daily-free-limit: 3
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.hibernate.SQL: debug
|
||||
@@ -0,0 +1,148 @@
|
||||
-- ============================================================
|
||||
-- 산책갈개 (dognation) 초기 스키마
|
||||
-- PostgreSQL 15+ / PostGIS 3.x
|
||||
-- ============================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1. 사용자 & 구독
|
||||
-- ------------------------------------------------------------
|
||||
-- 구독 티어: FREE / AD_FREE(광고제거) / PREMIUM(AI 무제한)
|
||||
CREATE TABLE users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(50) NOT NULL,
|
||||
subscription_tier VARCHAR(20) NOT NULL DEFAULT 'FREE'
|
||||
CHECK (subscription_tier IN ('FREE', 'AD_FREE', 'PREMIUM')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('ACTIVE', 'DORMANT', 'BANNED')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 인앱결제(IAP) 구독 이력 - 애플/구글 영수증 기준
|
||||
CREATE TABLE subscriptions (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tier VARCHAR(20) NOT NULL CHECK (tier IN ('AD_FREE', 'PREMIUM')),
|
||||
platform VARCHAR(20) NOT NULL CHECK (platform IN ('APPLE', 'GOOGLE')),
|
||||
original_txn_id VARCHAR(255) NOT NULL, -- 스토어 원본 거래 ID (갱신 추적용)
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_subscriptions_user_active ON subscriptions(user_id, is_active);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2. 강아지 프로필 & 댕비티아이(성향 태그)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE dogs (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
breed VARCHAR(100),
|
||||
birth_date DATE,
|
||||
size VARCHAR(20) CHECK (size IN ('SMALL', 'MEDIUM', 'LARGE')),
|
||||
gender VARCHAR(10) CHECK (gender IN ('MALE', 'FEMALE')),
|
||||
neutered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
image_url VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_dogs_user ON dogs(user_id);
|
||||
|
||||
-- 성향 태그 마스터 (예: 대형견무서워함, 터그놀이매니아, 사회화훈련중)
|
||||
CREATE TABLE trait_tags (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL UNIQUE, -- 시스템 식별용
|
||||
label VARCHAR(100) NOT NULL, -- 화면 노출 문구
|
||||
category VARCHAR(30) NOT NULL -- 성격 / 놀이 / 주의사항 등
|
||||
);
|
||||
|
||||
-- 강아지 <-> 성향 태그 (N:N)
|
||||
CREATE TABLE dog_trait_tags (
|
||||
dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES trait_tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (dog_id, tag_id)
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3. 하루 AI 매칭 쿼터 (스케줄러가 매일 자정 초기화)
|
||||
-- ------------------------------------------------------------
|
||||
-- 사용자당 1행. remaining_count 를 소진하며, 자정 배치로 daily_free_limit 만큼 리셋.
|
||||
-- PREMIUM 은 무제한이므로 배치 대상에서 제외(서비스단에서 차감 자체를 스킵).
|
||||
CREATE TABLE daily_match_quota (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
remaining_count INT NOT NULL DEFAULT 3,
|
||||
last_reset_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 매칭 신청 (요청 견 -> 대상 견)
|
||||
CREATE TABLE matching_requests (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
requester_dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
target_dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
|
||||
CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELED')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
responded_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX idx_matching_target ON matching_requests(target_dog_id, status);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4. 가상 스팟(Anchor) 체크인 & 한줄평
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE spots (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
type VARCHAR(30) NOT NULL DEFAULT 'PARK'
|
||||
CHECK (type IN ('PARK', 'TRAIL', 'PLAYGROUND', 'ETC')),
|
||||
-- WGS84(4326) 위경도 포인트. GPS 동선 대신 '고정 스팟' 좌표만 저장.
|
||||
geom geography(Point, 4326) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- 반경 검색(내 주변 스팟)용 공간 인덱스
|
||||
CREATE INDEX idx_spots_geom ON spots USING GIST (geom);
|
||||
|
||||
-- 체크인 (휘발성: expires_at 지나면 만료 처리)
|
||||
CREATE TABLE check_ins (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
spot_id BIGINT NOT NULL REFERENCES spots(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
dog_id BIGINT NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
checked_in_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL -- 예: 체크인 후 2시간
|
||||
);
|
||||
CREATE INDEX idx_checkins_spot_active ON check_ins(spot_id, expires_at);
|
||||
|
||||
-- 스팟 한줄평 (네이버 지도식 태그형 리뷰: "진드기많아요", "풀숲무성함")
|
||||
CREATE TABLE spot_reviews (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
spot_id BIGINT NOT NULL REFERENCES spots(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag VARCHAR(50), -- 태그형 한줄평
|
||||
content VARCHAR(200), -- 자유 코멘트(선택)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_reviews_spot ON spot_reviews(spot_id, created_at DESC);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5. 실시간 인앱 채팅 (휘발성)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE chat_rooms (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
spot_id BIGINT REFERENCES spots(id) ON DELETE SET NULL, -- 스팟 기반 오픈채팅
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
room_id BIGINT NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
|
||||
sender_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content VARCHAR(1000) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_messages_room ON chat_messages(room_id, created_at);
|
||||
Reference in New Issue
Block a user