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:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.3.4'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.6'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'com.dog'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
|
||||||
|
// PostGIS / 공간 데이터 (스팟 위경도 좌표)
|
||||||
|
implementation 'org.hibernate.orm:hibernate-spatial'
|
||||||
|
|
||||||
|
// DB 마이그레이션
|
||||||
|
implementation 'org.flywaydb:flyway-core'
|
||||||
|
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('test') {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'dognation_BT'
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.dog.dognation.scheduler;
|
||||||
|
|
||||||
|
import com.dog.dognation.domain.matching.DailyMatchQuota;
|
||||||
|
import com.dog.dognation.domain.matching.DailyMatchQuotaRepository;
|
||||||
|
import com.dog.dognation.domain.matching.MatchQuotaService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자정 매칭 쿼터 초기화 스케줄러 검증.
|
||||||
|
* 실제 스케줄러 빈 -> 서비스 -> 리포지토리(JPQL 벌크 UPDATE) 경로를 그대로 실행한다.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class MatchQuotaSchedulerTest {
|
||||||
|
|
||||||
|
@Autowired MatchQuotaScheduler scheduler;
|
||||||
|
@Autowired MatchQuotaService quotaService;
|
||||||
|
@Autowired DailyMatchQuotaRepository quotaRepository;
|
||||||
|
@Autowired JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private long freeUserId;
|
||||||
|
private long adFreeUserId;
|
||||||
|
private long premiumUserId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
jdbc.update("DELETE FROM daily_match_quota");
|
||||||
|
jdbc.update("DELETE FROM users");
|
||||||
|
|
||||||
|
// 잔여 0으로 소진된 상태를 만들어 초기화가 실제로 일어나는지 관찰
|
||||||
|
freeUserId = seedUser("free@dog.com", "FREE", 0);
|
||||||
|
adFreeUserId = seedUser("adfree@dog.com", "AD_FREE", 0);
|
||||||
|
premiumUserId = seedUser("premium@dog.com", "PREMIUM", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("자정 배치: FREE/AD_FREE 는 3으로 초기화, PREMIUM 은 손대지 않는다")
|
||||||
|
void reset_resetsNonPremiumOnly() {
|
||||||
|
scheduler.resetDailyMatchQuota();
|
||||||
|
|
||||||
|
assertThat(remaining(freeUserId)).isEqualTo(3);
|
||||||
|
assertThat(remaining(adFreeUserId)).isEqualTo(3);
|
||||||
|
assertThat(remaining(premiumUserId)).isEqualTo(0); // PREMIUM 은 배치 대상 아님(무제한)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("초기화 대상(비 PREMIUM) 행 수를 정확히 반환한다")
|
||||||
|
void reset_returnsAffectedRowCount() {
|
||||||
|
int updated = quotaService.resetAll();
|
||||||
|
assertThat(updated).isEqualTo(2); // FREE + AD_FREE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("소진 로직: 3회까지 차감되고 이후 신청은 차단된다")
|
||||||
|
void consume_blocksAfterLimit() {
|
||||||
|
// 쿼터 행이 3으로 초기화된 상태에서 시작
|
||||||
|
scheduler.resetDailyMatchQuota();
|
||||||
|
|
||||||
|
assertThat(quotaService.consumeOne(freeUserId)).isTrue(); // 3 -> 2
|
||||||
|
assertThat(quotaService.consumeOne(freeUserId)).isTrue(); // 2 -> 1
|
||||||
|
assertThat(quotaService.consumeOne(freeUserId)).isTrue(); // 1 -> 0
|
||||||
|
assertThat(quotaService.consumeOne(freeUserId)).isFalse(); // 0 -> 차단
|
||||||
|
assertThat(remaining(freeUserId)).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private long seedUser(String email, String tier, int remaining) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO users(email, password_hash, nickname, subscription_tier, status, created_at, updated_at)
|
||||||
|
VALUES (?, 'x', ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
""", email, email, tier);
|
||||||
|
Long id = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, email);
|
||||||
|
quotaRepository.save(new DailyMatchQuota(id, remaining, java.time.LocalDate.now()));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int remaining(long userId) {
|
||||||
|
Integer r = jdbc.queryForObject(
|
||||||
|
"SELECT remaining_count FROM daily_match_quota WHERE user_id = ?", Integer.class, userId);
|
||||||
|
return r == null ? -1 : r;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 스케줄러 검증용 테스트 프로파일 — 인메모리 H2(PostgreSQL 호환 모드)
|
||||||
|
# PostGIS/Flyway 는 이 검증과 무관하므로 비활성화하고, 엔티티에서 테이블 생성.
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:dognation;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.H2Dialect
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
app:
|
||||||
|
scheduler:
|
||||||
|
timezone: Asia/Seoul
|
||||||
|
matching:
|
||||||
|
daily-free-limit: 3
|
||||||
Reference in New Issue
Block a user