Compare commits
28 Commits
sql
...
recurrents
| Author | SHA1 | Date | |
|---|---|---|---|
| 12afd1f90e | |||
| d0cae182b7 | |||
| 0f02b53bc0 | |||
| b08ab909c8 | |||
| a65f46aff3 | |||
| 036ad00795 | |||
| 1c3605623e | |||
| aaa12fcb86 | |||
| cef82c483f | |||
| e83e3a2b65 | |||
| c68e6afb8a | |||
| 10b7c730ad | |||
| a0024def2e | |||
| d2458633db | |||
| 5b9d2366db | |||
| a30eb52f3f | |||
| 175c86d787 | |||
| 3541528e77 | |||
| 62fe88abdf | |||
| 61bfdf273a | |||
| 4a17b4f528 | |||
| 50607a8c42 | |||
| 3bb8c33094 | |||
| 1bc5932793 | |||
| 8d1b0f2a3c | |||
| 224fe18639 | |||
| d32062a485 | |||
| 0b54384258 |
32
Dockerfile
32
Dockerfile
@@ -1,31 +1,15 @@
|
||||
# ---------- build stage ----------
|
||||
FROM gradle:jdk17-ubi AS build
|
||||
WORKDIR /app
|
||||
COPY gradlew gradlew
|
||||
COPY gradle gradle
|
||||
COPY build.gradle.kts settings.gradle.kts ./
|
||||
COPY src src
|
||||
RUN ./gradlew --no-daemon dependencies
|
||||
RUN ./gradlew --no-daemon clean bootJar
|
||||
|
||||
# ---------- run stage ----------
|
||||
FROM eclipse-temurin:17.0.16_8-jre AS runtime
|
||||
FROM eclipse-temurin:17-jre AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user with a higher UID/GID to avoid conflicts
|
||||
RUN groupadd --system --gid 1001 app && \
|
||||
useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
|
||||
|
||||
# Создаём директорию и меняем владельца ДО переключения пользователя
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN groupadd --system --gid 1001 app && useradd --system --gid app --uid 1001 --shell /bin/bash --create-home app
|
||||
RUN mkdir -p /app/static && chown -R app:app /app
|
||||
|
||||
COPY build/libs/luminic-space-v2.jar /app/luminic-space-v2.jar
|
||||
USER app
|
||||
|
||||
COPY --from=build /app/build/libs/*.jar /app/app.jar
|
||||
|
||||
# Настройки JVM (Java 17)
|
||||
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD wget -qO- http://localhost:8080/actuator/health || exit 1
|
||||
ENTRYPOINT ["java","-jar","/app/app.jar"]
|
||||
HEALTHCHECK --interval=20s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
||||
|
||||
ENTRYPOINT ["java","-jar","/app/luminic-space-v2.jar"]
|
||||
@@ -29,8 +29,11 @@ configurations {
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
|
||||
|
||||
|
||||
dependencies {
|
||||
// Spring
|
||||
implementation("org.springframework.boot:spring-boot-starter-cache")
|
||||
@@ -53,6 +56,12 @@ dependencies {
|
||||
|
||||
implementation("commons-logging:commons-logging:1.3.4")
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
@@ -67,10 +76,11 @@ dependencies {
|
||||
|
||||
implementation("io.micrometer:micrometer-registry-prometheus")
|
||||
|
||||
implementation("org.telegram:telegrambots:6.9.7.1")
|
||||
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
|
||||
// implementation("org.telegram:telegrambots:6.9.7.1")
|
||||
// implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
|
||||
implementation("com.opencsv:opencsv:5.10")
|
||||
|
||||
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
|
||||
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
|
||||
10
deploy.sh
Executable file
10
deploy.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
./gradlew bootJar || exit 1
|
||||
|
||||
scp build/libs/luminic-space-v2.jar root@213.226.71.138:/root/luminic/space/back
|
||||
|
||||
ssh root@213.226.71.138 "
|
||||
cd /root/luminic/space/back &&
|
||||
docker compose up -d --build &&
|
||||
docker restart back-app-1
|
||||
"
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
networks:
|
||||
postgres-net:
|
||||
external: true
|
||||
|
||||
services:
|
||||
app:
|
||||
image: back-app
|
||||
volumes:
|
||||
- ./luminic-space-v2.jar:/app/luminic-space-v2.jar
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db
|
||||
SPRING_DATASOURCE_USERNAME: luminicspace
|
||||
SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r!
|
||||
ports:
|
||||
- "8089:8089"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- postgres-net
|
||||
1
gradle.properties
Normal file
1
gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
kotlin.code.style=official
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
#Wed Oct 08 22:52:02 GET 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
234
gradlew
vendored
Executable file
234
gradlew
vendored
Executable file
@@ -0,0 +1,234 @@
|
||||
#!/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.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# 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/master/subprojects/plugins/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
|
||||
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=${0##*/}
|
||||
|
||||
# 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"'
|
||||
|
||||
# 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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# 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
|
||||
which java >/dev/null 2>&1 || 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
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
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
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# 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" "$@"
|
||||
89
gradlew.bat
vendored
Normal file
89
gradlew.bat
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
@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
|
||||
|
||||
@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=.
|
||||
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%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="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!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
4
settings.gradle.kts
Normal file
4
settings.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||
}
|
||||
rootProject.name = "luminic-space"
|
||||
@@ -1,23 +1,124 @@
|
||||
package space.luminic.finance.api
|
||||
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonBuilder
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import space.luminic.finance.dtos.UserDTO
|
||||
import space.luminic.finance.dtos.UserDTO.AuthUserDTO
|
||||
import space.luminic.finance.dtos.UserDTO.RegisterUserDTO
|
||||
import space.luminic.finance.mappers.UserMapper.toDto
|
||||
import space.luminic.finance.mappers.UserMapper.toTelegramMap
|
||||
import space.luminic.finance.services.AuthService
|
||||
import java.net.URLDecoder
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
class AuthController(
|
||||
private val authService: AuthService
|
||||
private val authService: AuthService,
|
||||
@Value("\${telegram.bot.token}") private val botToken: String
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun verifyTelegramAuth(
|
||||
loginData: Map<String, String>? = null, // from login widget
|
||||
webAppInitData: String? = null
|
||||
): Boolean {
|
||||
|
||||
// --- LOGIN WIDGET CHECK ---
|
||||
if (loginData != null) {
|
||||
val hash = loginData["hash"]
|
||||
if (hash != null) {
|
||||
val dataCheckString = loginData
|
||||
.filterKeys { it != "hash" }
|
||||
.toSortedMap()
|
||||
.map { "${it.key}=${it.value}" }
|
||||
.joinToString("\n")
|
||||
|
||||
val secretKey = MessageDigest.getInstance("SHA-256")
|
||||
.digest(botToken.toByteArray())
|
||||
|
||||
val hmac = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(secretKey, "HmacSHA256"))
|
||||
}.doFinal(dataCheckString.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false
|
||||
if (Instant.now().epochSecond - authDate > 3600) return false
|
||||
|
||||
if (hmac == hash) return true
|
||||
}
|
||||
}
|
||||
|
||||
// --- WEBAPP CHECK ---
|
||||
// --- WEBAPP CHECK ---
|
||||
if (webAppInitData != null) {
|
||||
// Разбираем query string корректно (учитывая '=' внутри значения)
|
||||
val pairs: Map<String, String> = webAppInitData.split("&")
|
||||
.mapNotNull { part ->
|
||||
val idx = part.indexOf('=')
|
||||
if (idx <= 0) return@mapNotNull null
|
||||
val k = part.substring(0, idx)
|
||||
val v = part.substring(idx + 1)
|
||||
k to URLDecoder.decode(v, Charsets.UTF_8.name())
|
||||
}.toMap()
|
||||
|
||||
val receivedHash = pairs["hash"] ?: return false
|
||||
|
||||
// Строка для подписи: все поля КРОМЕ hash, отсортированные по ключу, в формате key=value с \n
|
||||
val dataCheckString = pairs
|
||||
.filterKeys { it != "hash" }
|
||||
.toSortedMap()
|
||||
.entries
|
||||
.joinToString("\n") { (k, v) -> "$k=$v" }
|
||||
|
||||
// ВАЖНО: secret_key = HMAC_SHA256(message=botToken, key="WebAppData")
|
||||
val secretKeyBytes = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec("WebAppData".toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||
}.doFinal(botToken.toByteArray(Charsets.UTF_8))
|
||||
|
||||
// hash = HMAC_SHA256(message=data_check_string, key=secret_key)
|
||||
val calcHashHex = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(secretKeyBytes, "HmacSHA256"))
|
||||
}.doFinal(dataCheckString.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
// опциональная проверка свежести
|
||||
val authDate = pairs["auth_date"]?.toLongOrNull() ?: return false
|
||||
val now = Instant.now().epochSecond
|
||||
val ttl = 6 * 3600L // например, 6 часов для WebApp
|
||||
val skew = 300L // допускаем до 5 минут будущего/прошлого
|
||||
val diff = now - authDate
|
||||
if (diff > ttl || diff < -skew) return false
|
||||
|
||||
if (calcHashHex == receivedHash) return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun sha256(input: String): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
|
||||
|
||||
private fun hmacSha256(secret: ByteArray, message: String): String {
|
||||
val key = SecretKeySpec(secret, "HmacSHA256")
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(key)
|
||||
val hashBytes = mac.doFinal(message.toByteArray())
|
||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/test")
|
||||
fun test(): String {
|
||||
val authentication = SecurityContextHolder.getContext().authentication
|
||||
@@ -36,10 +137,42 @@ class AuthController(
|
||||
return authService.register(request.username, request.password, request.firstName).toDto()
|
||||
}
|
||||
|
||||
@PostMapping("/tgLogin")
|
||||
fun tgLogin(@RequestHeader("X-Tg-Id") tgId: String): Map<String, String> {
|
||||
val token = authService.tgLogin(tgId)
|
||||
return mapOf("token" to token)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
|
||||
@PostMapping("/tg-login")
|
||||
fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map<String, String> {
|
||||
// println(tgUser.hash)
|
||||
// println(botToken)
|
||||
if (tgUser.initData == null) {
|
||||
if (verifyTelegramAuth(
|
||||
loginData = tgUser.toTelegramMap(),
|
||||
)
|
||||
) {
|
||||
return mapOf("token" to authService.tgAuth(tgUser))
|
||||
} else throw IllegalArgumentException("Invalid Telegram login")
|
||||
} else {
|
||||
if (verifyTelegramAuth(webAppInitData = tgUser.initData)) {
|
||||
val params = tgUser.initData.split("&").associate {
|
||||
val (k, v) = it.split("=", limit = 2)
|
||||
k to URLDecoder.decode(v, "UTF-8")
|
||||
}
|
||||
|
||||
val userJson = params["user"] ?: error("No user data")
|
||||
val jsonUser = json.decodeFromString<UserDTO.TelegramUserData>(userJson)
|
||||
val newUser = UserDTO.TelegramAuthDTO(
|
||||
jsonUser.id,
|
||||
jsonUser.first_name,
|
||||
jsonUser.last_name,
|
||||
jsonUser.username,
|
||||
jsonUser.photo_url,
|
||||
null,
|
||||
hash = tgUser.hash,
|
||||
initData = null,
|
||||
)
|
||||
return mapOf("token" to authService.tgAuth(newUser))
|
||||
} else throw IllegalArgumentException("Invalid Telegram login")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class SpaceController(
|
||||
|
||||
@GetMapping("/{spaceId}")
|
||||
fun getSpace(@PathVariable spaceId: Int): SpaceDTO {
|
||||
return spaceService.getSpace(spaceId).toDto()
|
||||
return spaceService.getSpace(spaceId, null).toDto()
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@@ -21,7 +21,7 @@ class BearerTokenFilter(
|
||||
private val publicMatchers = listOf(
|
||||
AntPathRequestMatcher("/auth/login", "POST"),
|
||||
AntPathRequestMatcher("/auth/register", "POST"),
|
||||
AntPathRequestMatcher("/auth/tgLogin", "POST"),
|
||||
AntPathRequestMatcher("/auth/tg-login", "POST"),
|
||||
AntPathRequestMatcher("/actuator/**"),
|
||||
AntPathRequestMatcher("/static/**"),
|
||||
AntPathRequestMatcher("/wishlistexternal/**"),
|
||||
|
||||
@@ -31,7 +31,7 @@ class SecurityConfig(
|
||||
.logout { it.disable() }
|
||||
|
||||
.authorizeHttpRequests {
|
||||
it.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tgLogin").permitAll()
|
||||
it.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tg-login").permitAll()
|
||||
it.requestMatchers("/actuator/**", "/static/**").permitAll()
|
||||
it.requestMatchers("/wishlistexternal/**").permitAll()
|
||||
it.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
|
||||
@@ -50,7 +50,7 @@ class SecurityConfig(
|
||||
@Bean
|
||||
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||
val cors = CorsConfiguration().apply {
|
||||
allowedOrigins = listOf("https://luminic.space", "http://localhost:5173")
|
||||
allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173")
|
||||
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
allowedHeaders = listOf("*")
|
||||
allowCredentials = true
|
||||
|
||||
@@ -11,7 +11,7 @@ data class TransactionDTO(
|
||||
var parentId: Int? = null,
|
||||
val type: TransactionType = TransactionType.EXPENSE,
|
||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
val category: CategoryDTO,
|
||||
val category: CategoryDTO? = null,
|
||||
val comment: String,
|
||||
val amount: BigDecimal,
|
||||
val fees: BigDecimal = BigDecimal.ZERO,
|
||||
@@ -23,17 +23,18 @@ data class TransactionDTO(
|
||||
data class CreateTransactionDTO(
|
||||
val type: TransactionType = TransactionType.EXPENSE,
|
||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
val categoryId: Int,
|
||||
val categoryId: Int? = null,
|
||||
val comment: String,
|
||||
val amount: BigDecimal,
|
||||
val fees: BigDecimal = BigDecimal.ZERO,
|
||||
val date: LocalDate,
|
||||
val recurrentId: Int? = null
|
||||
)
|
||||
|
||||
data class UpdateTransactionDTO(
|
||||
val type: TransactionType = TransactionType.EXPENSE,
|
||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
val categoryId: Int,
|
||||
val categoryId: Int? = null,
|
||||
val comment: String,
|
||||
val amount: BigDecimal,
|
||||
val fees: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package space.luminic.finance.dtos
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
data class UserDTO(
|
||||
var id: Int,
|
||||
val username: String,
|
||||
var firstName: String,
|
||||
var tgId: String? = null,
|
||||
var tgId: Long? = null,
|
||||
var tgUserName: String? = null,
|
||||
var photoUrl: String? = null,
|
||||
var roles: List<String>
|
||||
|
||||
) {
|
||||
@@ -22,6 +25,29 @@ data class UserDTO (
|
||||
|
||||
)
|
||||
|
||||
data class TelegramAuthDTO(
|
||||
val id: Long?,
|
||||
val first_name: String?,
|
||||
val last_name: String?,
|
||||
val username: String?,
|
||||
val photo_url: String?,
|
||||
val auth_date: Long?,
|
||||
val hash: String,
|
||||
val initData: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class TelegramUserData(
|
||||
val id: Long,
|
||||
val first_name: String,
|
||||
val last_name: String? = null,
|
||||
val username: String? = null,
|
||||
val photo_url: String? = null,
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ object TransactionMapper {
|
||||
parentId = this.parent?.id,
|
||||
type = this.type,
|
||||
kind = this.kind,
|
||||
category = this.category.toDto(),
|
||||
category = this.category?.toDto(),
|
||||
comment = this.comment,
|
||||
amount = this.amount,
|
||||
fees = this.fees,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package space.luminic.finance.mappers
|
||||
|
||||
import space.luminic.finance.dtos.UserDTO
|
||||
import space.luminic.finance.dtos.UserDTO.TelegramAuthDTO
|
||||
import space.luminic.finance.models.User
|
||||
|
||||
object UserMapper {
|
||||
@@ -11,7 +12,18 @@ object UserMapper {
|
||||
firstName = this.firstName,
|
||||
tgId = this.tgId,
|
||||
tgUserName = this.tgUserName,
|
||||
photoUrl = this.photoUrl,
|
||||
roles = this.roles
|
||||
)
|
||||
|
||||
fun TelegramAuthDTO.toTelegramMap(): Map<String, String> =
|
||||
mapOf(
|
||||
"id" to id.toString(),
|
||||
"first_name" to (first_name ?: ""),
|
||||
"last_name" to (last_name ?: ""),
|
||||
"username" to (username ?: ""),
|
||||
"photo_url" to (photo_url ?: ""),
|
||||
"auth_date" to auth_date.toString(),
|
||||
"hash" to hash
|
||||
)
|
||||
}
|
||||
16
src/main/kotlin/space/luminic/finance/models/State.kt
Normal file
16
src/main/kotlin/space/luminic/finance/models/State.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package space.luminic.finance.models
|
||||
|
||||
data class State(
|
||||
val user: User,
|
||||
val state: StateCode = StateCode.AWAIT_SPACE_SELECT,
|
||||
val data: Map<String, String> = mapOf()
|
||||
) {
|
||||
enum class StateCode {
|
||||
AWAIT_SPACE_SELECT,
|
||||
SPACE_SELECTED,
|
||||
AWAIT_TRANSACTION,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ data class Transaction(
|
||||
var parent: Transaction? = null,
|
||||
val type: TransactionType = TransactionType.EXPENSE,
|
||||
val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
val category: Category,
|
||||
val category: Category? = null,
|
||||
val comment: String,
|
||||
val amount: BigDecimal,
|
||||
val fees: BigDecimal = BigDecimal.ZERO,
|
||||
@@ -25,6 +25,9 @@ data class Transaction(
|
||||
@CreatedDate var createdAt: Instant? = null,
|
||||
@LastModifiedBy var updatedBy: User? = null,
|
||||
@LastModifiedDate var updatedAt: Instant? = null,
|
||||
val tgChatId: Long? = null,
|
||||
val tgMessageId: Long? = null,
|
||||
val recurrentId: Int? = null,
|
||||
) {
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ data class User(
|
||||
var id: Int? = null,
|
||||
val username: String,
|
||||
var firstName: String,
|
||||
var tgId: String? = null,
|
||||
var tgId: Long? = null,
|
||||
var tgUserName: String? = null,
|
||||
val photoUrl: String? = null,
|
||||
var password: String? = null,
|
||||
var isActive: Boolean = true,
|
||||
var regDate: LocalDate = LocalDate.now(),
|
||||
|
||||
9
src/main/kotlin/space/luminic/finance/repos/BotRepo.kt
Normal file
9
src/main/kotlin/space/luminic/finance/repos/BotRepo.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package space.luminic.finance.repos
|
||||
|
||||
import space.luminic.finance.models.State
|
||||
|
||||
interface BotRepo {
|
||||
fun getState(tgUserId: Long): State?
|
||||
fun setState(userId: Int, stateCode: State.StateCode, stateData: Map<String, String>)
|
||||
fun clearState(userId: Int)
|
||||
}
|
||||
114
src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt
Normal file
114
src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
package space.luminic.finance.repos
|
||||
|
||||
import org.springframework.jdbc.core.ResultSetExtractor
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import space.luminic.finance.models.State
|
||||
import space.luminic.finance.models.User
|
||||
|
||||
@Repository
|
||||
class BotRepoImpl(
|
||||
private val jdbcTemplate: NamedParameterJdbcTemplate,
|
||||
) : BotRepo {
|
||||
override fun getState(tgUserId: Long): State? {
|
||||
val sql = """
|
||||
select
|
||||
bs.user_id as bs_user_id,
|
||||
u.username as u_username,
|
||||
u.first_name as u_first_name,
|
||||
bs.state_code as bs_state_code,
|
||||
bsd.data_code as bs_data_code,
|
||||
bsd.data_value as bs_data_value
|
||||
from finance.bot_states bs
|
||||
join finance.users u on u.id = bs.user_id
|
||||
left join finance.bot_states_data bsd on bsd.user_id = bs.user_id
|
||||
where u.tg_id = :user_id
|
||||
""".trimIndent()
|
||||
|
||||
val params = mapOf("user_id" to tgUserId)
|
||||
|
||||
return jdbcTemplate.query(sql, params, ResultSetExtractor { rs ->
|
||||
var user: User? = null
|
||||
var stateCode: State.StateCode? = null
|
||||
val data = mutableMapOf<String, String>()
|
||||
|
||||
while (rs.next()) {
|
||||
if (user == null) {
|
||||
user = User(
|
||||
id = rs.getInt("bs_user_id"),
|
||||
username = rs.getString("u_username"),
|
||||
firstName = rs.getString("u_first_name")
|
||||
)
|
||||
stateCode = rs.getString("bs_state_code")?.let { raw ->
|
||||
runCatching { State.StateCode.valueOf(raw) }
|
||||
.getOrElse { State.StateCode.AWAIT_SPACE_SELECT }
|
||||
}
|
||||
}
|
||||
val code = rs.getString("bs_data_code")
|
||||
val value = rs.getString("bs_data_value")
|
||||
if (code != null && value != null) {
|
||||
data[code] = value
|
||||
}
|
||||
}
|
||||
|
||||
user?.let {
|
||||
State(
|
||||
user = it,
|
||||
state = stateCode ?: State.StateCode.AWAIT_SPACE_SELECT,
|
||||
data = data.toMap()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun setState(
|
||||
userId: Int,
|
||||
stateCode: State.StateCode,
|
||||
stateData: Map<String, String>
|
||||
) {
|
||||
// 1) UPSERT state (по user_id)
|
||||
val upsertStateSql = """
|
||||
INSERT INTO finance.bot_states (user_id, state_code)
|
||||
VALUES (:user_id, :state_code)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET state_code = EXCLUDED.state_code
|
||||
""".trimIndent()
|
||||
|
||||
jdbcTemplate.update(
|
||||
upsertStateSql,
|
||||
mapOf(
|
||||
"user_id" to userId,
|
||||
// если в БД enum — чаще всего ок передать name(); если колонка TEXT/VARCHAR — тоже ок
|
||||
"state_code" to stateCode.name
|
||||
)
|
||||
)
|
||||
|
||||
// 2) Обновление data: вариант A — апсерты (рекомендуется)
|
||||
if (stateData.isNotEmpty()) {
|
||||
val upsertDataSql = """
|
||||
INSERT INTO finance.bot_states_data (user_id, data_code, data_value)
|
||||
VALUES (:user_id, :data_code, :data_value)
|
||||
ON CONFLICT (user_id, data_code) DO UPDATE
|
||||
SET data_value = EXCLUDED.data_value
|
||||
""".trimIndent()
|
||||
|
||||
val batch = stateData.map { (code, value) ->
|
||||
mapOf(
|
||||
"user_id" to userId,
|
||||
"data_code" to code,
|
||||
"data_value" to value
|
||||
)
|
||||
}.toTypedArray()
|
||||
|
||||
jdbcTemplate.batchUpdate(upsertDataSql, batch)
|
||||
}
|
||||
|
||||
// Если тебе принципиально "перезаписывать" состояние данных (вариант B):
|
||||
// np.update("DELETE FROM finance.bot_states_data WHERE user_id = :user_id", mapOf("user_id" to userId))
|
||||
// затем обычный INSERT batch без ON CONFLICT.
|
||||
}
|
||||
|
||||
override fun clearState(userId: Int) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class CategoryRepoImpl(
|
||||
}
|
||||
|
||||
override fun findBySpaceId(spaceId: Int): List<Category> {
|
||||
val query = "select * from finance.categories where space_id = :space_id order by id"
|
||||
val query = "select * from finance.categories where space_id = :space_id order by name"
|
||||
val params = mapOf("space_id" to spaceId)
|
||||
return jdbcTemplate.query(query, params, categoryRowMapper())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import space.luminic.finance.models.RecurrentOperation
|
||||
interface RecurrentOperationRepo {
|
||||
fun findAllBySpaceId(spaceId: Int): List<RecurrentOperation>
|
||||
fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation?
|
||||
fun findByDate( date: Int): List<RecurrentOperation>
|
||||
fun create(operation: RecurrentOperation, createdById: Int): Int
|
||||
fun update(operation: RecurrentOperation, updatedById: Int)
|
||||
fun delete(id: Int)
|
||||
fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation>
|
||||
}
|
||||
@@ -115,6 +115,40 @@ class RecurrentOperationRepoImpl(
|
||||
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
|
||||
}
|
||||
|
||||
override fun findByDate(
|
||||
date: Int
|
||||
): List<RecurrentOperation> {
|
||||
val sql = """
|
||||
select
|
||||
ro.id as r_id,
|
||||
ro.space_id AS r_space_id,
|
||||
s.name AS s_name,
|
||||
s.owner_id as s_owner_id,
|
||||
su.username as su_username,
|
||||
su.first_name AS su_first_name,
|
||||
ro.category_id as r_category_id,
|
||||
c.type AS c_type,
|
||||
c.name AS c_name,
|
||||
c.description AS c_description,
|
||||
c.icon AS c_icon,
|
||||
ro.name AS r_name,
|
||||
ro.amount AS r_amount,
|
||||
ro.date AS r_date,
|
||||
ro.created_by_id as r_created_by_id,
|
||||
r_created_by.username as r_created_by_username,
|
||||
r_created_by.first_name as r_created_by_first_name,
|
||||
ro.created_at as r_created_at
|
||||
from finance.recurrent_operations ro
|
||||
join finance.spaces s on ro.space_id = s.id
|
||||
join finance.users su on s.owner_id = su.id
|
||||
join finance.categories c on ro.category_id = c.id
|
||||
join finance.users r_created_by on ro.created_by_id = r_created_by.id
|
||||
where ro.date = :date
|
||||
""".trimIndent()
|
||||
val params = mapOf( "date" to date)
|
||||
return jdbcTemplate.query(sql, params, operationRowMapper())
|
||||
}
|
||||
|
||||
override fun create(operation: RecurrentOperation, createdById: Int): Int {
|
||||
val sql = """
|
||||
insert into finance.recurrent_operations (
|
||||
@@ -175,4 +209,13 @@ class RecurrentOperationRepoImpl(
|
||||
val params = mapOf("id" to id)
|
||||
jdbcTemplate.update(sql, params)
|
||||
}
|
||||
|
||||
override fun findRecurrentsToCreate(spaceId: Int): List<RecurrentOperation> {
|
||||
val sql = """
|
||||
select * from finance.transactions where space_id = :spaceId and t.date >
|
||||
""".trimIndent()
|
||||
TODO("Not ready")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -64,8 +64,8 @@ class TokenRepoImpl(
|
||||
update finance.tokens set status = :status where token = :token
|
||||
""".trimIndent()
|
||||
val params = mapOf(
|
||||
"token" to token,
|
||||
"status" to token.status
|
||||
"token" to token.token,
|
||||
"status" to token.status.name
|
||||
)
|
||||
jdbcTemplate.update(sql, params)
|
||||
return token
|
||||
|
||||
@@ -6,7 +6,11 @@ interface TransactionRepo {
|
||||
fun findAllBySpaceId(spaceId: Int): List<Transaction>
|
||||
fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction?
|
||||
fun create(transaction: Transaction, userId: Int): Int
|
||||
fun createBatch(transactions: List<Transaction>, userId: Int)
|
||||
fun update(transaction: Transaction): Int
|
||||
fun delete(transactionId: Int)
|
||||
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
|
||||
|
||||
fun setCategory(txId:Int, categoryId: Int)
|
||||
|
||||
}
|
||||
@@ -13,12 +13,7 @@ class TransactionRepoImpl(
|
||||
) : TransactionRepo {
|
||||
|
||||
private fun transactionRowMapper() = RowMapper { rs, _ ->
|
||||
Transaction(
|
||||
id = rs.getInt("t_id"),
|
||||
parent = findBySpaceIdAndId(spaceId = rs.getInt("t_space_id"), rs.getInt("t_parent_id")),
|
||||
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
|
||||
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
|
||||
category = Category(
|
||||
val category = if (rs.getString("c_id") == null) null else Category(
|
||||
id = rs.getInt("c_id"),
|
||||
type = Category.CategoryType.valueOf(rs.getString("c_type")),
|
||||
name = rs.getString(("c_name")),
|
||||
@@ -26,8 +21,18 @@ class TransactionRepoImpl(
|
||||
icon = rs.getString("c_icon"),
|
||||
isDeleted = rs.getBoolean(("c_is_deleted")),
|
||||
createdAt = rs.getTimestamp("c_created_at").toInstant(),
|
||||
updatedAt = rs.getTimestamp("c_updated_at").toInstant(),
|
||||
),
|
||||
updatedAt = rs.getTimestamp("c_updated_at")?.toInstant(),
|
||||
)
|
||||
val parent = if (rs.getInt("t_parent_id") != 0) findBySpaceIdAndId(
|
||||
spaceId = rs.getInt("t_space_id"),
|
||||
rs.getInt("t_parent_id")
|
||||
) else null
|
||||
Transaction(
|
||||
id = rs.getInt("t_id"),
|
||||
parent = parent,
|
||||
type = Transaction.TransactionType.valueOf(rs.getString("t_type")),
|
||||
kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")),
|
||||
category = category,
|
||||
comment = rs.getString("t_comment"),
|
||||
amount = rs.getBigDecimal("t_amount"),
|
||||
fees = rs.getBigDecimal("t_fees"),
|
||||
@@ -41,6 +46,9 @@ class TransactionRepoImpl(
|
||||
),
|
||||
createdAt = rs.getTimestamp("t_created_at").toInstant(),
|
||||
updatedAt = rs.getTimestamp("t_updated_at").toInstant(),
|
||||
tgChatId = rs.getLong("tg_chat_id"),
|
||||
tgMessageId = rs.getLong("tg_message_id"),
|
||||
recurrentId = rs.getInt("t_recurrent_id"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,13 +78,15 @@ class TransactionRepoImpl(
|
||||
c.updated_at AS c_updated_at,
|
||||
u.id AS u_id,
|
||||
u.username AS u_username,
|
||||
u.first_name AS u_first_name
|
||||
|
||||
u.first_name AS u_first_name,
|
||||
t.tg_chat_id AS tg_chat_id,
|
||||
t.tg_message_id AS tg_message_id,
|
||||
t.recurrent_id AS t_recurrent_id
|
||||
FROM finance.transactions t
|
||||
JOIN finance.categories c ON t.category_id = c.id
|
||||
LEFT JOIN finance.categories c ON t.category_id = c.id
|
||||
JOIN finance.users u ON u.id = t.created_by_id
|
||||
WHERE t.space_id = :spaceId and t.is_deleted = false
|
||||
ORDER BY t.date
|
||||
ORDER BY t.date, t.id
|
||||
""".trimIndent()
|
||||
val params = mapOf(
|
||||
"spaceId" to spaceId,
|
||||
@@ -99,6 +109,8 @@ class TransactionRepoImpl(
|
||||
t.is_done AS t_is_done,
|
||||
t.created_at AS t_created_at,
|
||||
t.updated_at AS t_updated_at,
|
||||
t.tg_chat_id AS tg_chat_id,
|
||||
t.tg_message_id AS tg_message_id,
|
||||
c.id AS c_id,
|
||||
c.type AS c_type,
|
||||
c.name AS c_name,
|
||||
@@ -109,9 +121,10 @@ class TransactionRepoImpl(
|
||||
c.updated_at AS c_updated_at,
|
||||
u.id AS u_id,
|
||||
u.username AS u_username,
|
||||
u.first_name AS u_first_name
|
||||
u.first_name AS u_first_name,
|
||||
t.recurrent_id AS t_recurrent_id
|
||||
FROM finance.transactions t
|
||||
JOIN finance.categories c ON t.category_id = c.id
|
||||
LEFT JOIN finance.categories c ON t.category_id = c.id
|
||||
JOIN finance.users u ON u.id = t.created_by_id
|
||||
WHERE t.space_id = :spaceId and t.id = :id and t.is_deleted = false""".trimMargin()
|
||||
val params = mapOf(
|
||||
@@ -123,7 +136,7 @@ class TransactionRepoImpl(
|
||||
|
||||
override fun create(transaction: Transaction, userId: Int): Int {
|
||||
val sql = """
|
||||
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id) VALUES (
|
||||
INSERT INTO finance.transactions (space_id, parent_id, type, kind, category_id, comment, amount, fees, date, is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id) VALUES (
|
||||
:spaceId,
|
||||
:parentId,
|
||||
:type,
|
||||
@@ -135,7 +148,10 @@ class TransactionRepoImpl(
|
||||
:date,
|
||||
:is_deleted,
|
||||
:is_done,
|
||||
:createdById)
|
||||
:createdById,
|
||||
:tgChatId,
|
||||
:tgMessageId,
|
||||
:recurrentId)
|
||||
returning id
|
||||
""".trimIndent()
|
||||
val params = mapOf(
|
||||
@@ -143,20 +159,57 @@ class TransactionRepoImpl(
|
||||
"parentId" to transaction.parent?.id,
|
||||
"type" to transaction.type.name,
|
||||
"kind" to transaction.kind.name,
|
||||
"categoryId" to transaction.category.id,
|
||||
"categoryId" to transaction.category?.id,
|
||||
"comment" to transaction.comment,
|
||||
"amount" to transaction.amount,
|
||||
"fees" to transaction.fees,
|
||||
"date" to transaction.date,
|
||||
"is_deleted" to transaction.isDeleted,
|
||||
"is_done" to transaction.isDone,
|
||||
"createdById" to userId
|
||||
"createdById" to userId,
|
||||
"tgChatId" to transaction.tgChatId,
|
||||
"tgMessageId" to transaction.tgMessageId,
|
||||
"recurrentId" to transaction.recurrentId,
|
||||
)
|
||||
val createdTxId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
|
||||
transaction.id = createdTxId
|
||||
return createdTxId!!
|
||||
}
|
||||
|
||||
override fun createBatch(transactions: List<Transaction>, userId: Int) {
|
||||
val sql = """
|
||||
INSERT INTO finance.transactions (
|
||||
space_id, parent_id, type, kind, category_id, comment, amount, fees, date,
|
||||
is_deleted, is_done, created_by_id, tg_chat_id, tg_message_id, recurrent_id
|
||||
) VALUES (
|
||||
:spaceId, :parentId, :type, :kind, :categoryId, :comment, :amount, :fees, :date,
|
||||
:is_deleted, :is_done, :createdById, :tgChatId, :tgMessageId, :recurrentId
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
val batchValues = transactions.map { transaction ->
|
||||
mapOf(
|
||||
"spaceId" to transaction.space!!.id,
|
||||
"parentId" to transaction.parent?.id,
|
||||
"type" to transaction.type.name,
|
||||
"kind" to transaction.kind.name,
|
||||
"categoryId" to transaction.category?.id,
|
||||
"comment" to transaction.comment,
|
||||
"amount" to transaction.amount,
|
||||
"fees" to transaction.fees,
|
||||
"date" to transaction.date,
|
||||
"is_deleted" to transaction.isDeleted,
|
||||
"is_done" to transaction.isDone,
|
||||
"createdById" to userId,
|
||||
"tgChatId" to transaction.tgChatId,
|
||||
"tgMessageId" to transaction.tgMessageId,
|
||||
"recurrentId" to transaction.recurrentId
|
||||
)
|
||||
}.toTypedArray()
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, batchValues)
|
||||
}
|
||||
|
||||
override fun update(transaction: Transaction): Int {
|
||||
// val type: TransactionType = TransactionType.EXPENSE,
|
||||
// val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
@@ -183,7 +236,7 @@ class TransactionRepoImpl(
|
||||
"id" to transaction.id,
|
||||
"type" to transaction.type.name,
|
||||
"kind" to transaction.kind.name,
|
||||
"categoryId" to transaction.category.id,
|
||||
"categoryId" to transaction.category?.id,
|
||||
"comment" to transaction.comment,
|
||||
"amount" to transaction.amount,
|
||||
"fees" to transaction.fees,
|
||||
@@ -203,4 +256,27 @@ class TransactionRepoImpl(
|
||||
)
|
||||
jdbcTemplate.update(sql, params)
|
||||
}
|
||||
|
||||
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
|
||||
val sql = """
|
||||
update finance.transactions set is_deleted = true where recurrent_id = :recurrentId
|
||||
""".trimIndent()
|
||||
val params = mapOf(
|
||||
"recurrentId" to recurrentId,
|
||||
)
|
||||
jdbcTemplate.update(sql, params)
|
||||
}
|
||||
|
||||
override fun setCategory(txId: Int, categoryId: Int) {
|
||||
val sql = """
|
||||
UPDATE finance.transactions
|
||||
SET category_id = :categoryId
|
||||
where id = :txId
|
||||
""".trimIndent()
|
||||
val params = mapOf(
|
||||
"categoryId" to categoryId,
|
||||
"txId" to txId,
|
||||
)
|
||||
jdbcTemplate.update(sql, params)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ interface UserRepo {
|
||||
fun findById(id: Int): User?
|
||||
fun findByUsername(username: String): User?
|
||||
fun findParticipantsBySpace(spaceId: Int): Set<User>
|
||||
fun findByTgId(tgId: String): User?
|
||||
fun save(user: User): User
|
||||
fun findByTgId(tgId: Long): User?
|
||||
fun create(user: User): User
|
||||
fun update(user: User): User
|
||||
fun deleteById(id: Long)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.springframework.jdbc.core.RowMapper
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import space.luminic.finance.models.User
|
||||
|
||||
@Repository
|
||||
class UserRepoImpl(
|
||||
private val jdbcTemplate: NamedParameterJdbcTemplate
|
||||
@@ -14,8 +15,9 @@ class UserRepoImpl(
|
||||
id = rs.getInt("id"),
|
||||
username = rs.getString("username"),
|
||||
firstName = rs.getString("first_name"),
|
||||
tgId = rs.getString("tg_id"),
|
||||
tgId = rs.getLong("tg_id"),
|
||||
tgUserName = rs.getString("tg_user_name"),
|
||||
photoUrl = rs.getString("photo_url"),
|
||||
password = rs.getString("password"),
|
||||
isActive = rs.getBoolean("is_active"),
|
||||
regDate = rs.getDate("reg_date").toLocalDate(),
|
||||
@@ -41,25 +43,28 @@ class UserRepoImpl(
|
||||
}
|
||||
|
||||
override fun findParticipantsBySpace(spaceId: Int): Set<User> {
|
||||
val sql = "select * from finance.users u join finance.spaces_participants sp on sp.participants_id = u.id where sp.space_id = :spaceId"
|
||||
val sql =
|
||||
"select * from finance.users u join finance.spaces_participants sp on sp.participants_id = u.id where sp.space_id = :spaceId"
|
||||
return jdbcTemplate.query(sql, mapOf("spaceId" to spaceId), userRowMapper()).toSet()
|
||||
}
|
||||
|
||||
override fun findByTgId(tgId: String): User? {
|
||||
override fun findByTgId(tgId: Long): User? {
|
||||
val sql = """
|
||||
select * from finance.users u where tg_id = :tgId
|
||||
""".trimIndent()
|
||||
val params = mapOf("tgId" to tgId)
|
||||
return jdbcTemplate.queryForObject(sql, params, userRowMapper())
|
||||
return jdbcTemplate.query(sql, params, userRowMapper()).firstOrNull()
|
||||
}
|
||||
|
||||
override fun save(user: User): User {
|
||||
val sql = "insert into finance.users(username, first_name, tg_id, tg_user_name, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :password, :isActive, :regDate) returning ID"
|
||||
override fun create(user: User): User {
|
||||
val sql =
|
||||
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate) returning ID"
|
||||
val params = mapOf(
|
||||
"username" to user.username,
|
||||
"firstname" to user.firstName,
|
||||
"tg_id" to user.tgId,
|
||||
"tg_user_name" to user.tgUserName,
|
||||
"photo_url" to user.photoUrl,
|
||||
"password" to user.password,
|
||||
"isActive" to user.isActive,
|
||||
"regDate" to user.regDate,
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.configs.AuthException
|
||||
import space.luminic.finance.dtos.UserDTO
|
||||
import space.luminic.finance.models.NotFoundException
|
||||
import space.luminic.finance.models.Token
|
||||
import space.luminic.finance.models.User
|
||||
import space.luminic.finance.repos.UserRepo
|
||||
@@ -61,10 +63,12 @@ class AuthService(
|
||||
}
|
||||
}
|
||||
|
||||
fun tgLogin(tgId: String): String {
|
||||
val user =
|
||||
userRepo.findByTgId(tgId) ?: throw UsernameNotFoundException("Пользователь не найден")
|
||||
|
||||
fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String {
|
||||
val user: User = try {
|
||||
tgLogin(tgUser.id!!)
|
||||
} catch (e: NotFoundException) {
|
||||
registerTg(tgUser)
|
||||
}
|
||||
val token = jwtUtil.generateToken(user.username)
|
||||
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
|
||||
tokenService.saveToken(
|
||||
@@ -73,7 +77,22 @@ class AuthService(
|
||||
expiresAt = expireAt.toInstant()
|
||||
)
|
||||
return token
|
||||
}
|
||||
|
||||
fun registerTg(tgUser: UserDTO.TelegramAuthDTO): User {
|
||||
val user = User(
|
||||
username = tgUser.username ?: UUID.randomUUID().toString().split('-')[0],
|
||||
firstName = tgUser.first_name ?: UUID.randomUUID().toString().split('-')[0],
|
||||
tgId = tgUser.id,
|
||||
tgUserName = tgUser.username,
|
||||
photoUrl = tgUser.photo_url,
|
||||
roles = mutableListOf("USER")
|
||||
)
|
||||
return userRepo.create(user)
|
||||
}
|
||||
|
||||
fun tgLogin(tgId: Long): User {
|
||||
return userRepo.findByTgId(tgId) ?: throw NotFoundException("User with provided TG id $tgId not found")
|
||||
}
|
||||
|
||||
fun register(username: String, password: String, firstName: String): User {
|
||||
@@ -85,7 +104,7 @@ class AuthService(
|
||||
firstName = firstName,
|
||||
roles = mutableListOf("USER")
|
||||
)
|
||||
newUser = userRepo.save(newUser)
|
||||
newUser = userRepo.create(newUser)
|
||||
return newUser
|
||||
} else throw IllegalArgumentException("Пользователь уже зарегистрирован")
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ interface RecurrentOperationService {
|
||||
fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int
|
||||
fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO)
|
||||
fun delete(spaceId: Int, id: Int)
|
||||
|
||||
fun createRecurrentTransactions()
|
||||
}
|
||||
@@ -1,19 +1,33 @@
|
||||
package space.luminic.finance.services
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.dtos.RecurrentOperationDTO
|
||||
import space.luminic.finance.models.Category
|
||||
import space.luminic.finance.models.NotFoundException
|
||||
import space.luminic.finance.models.RecurrentOperation
|
||||
import space.luminic.finance.models.Transaction
|
||||
import space.luminic.finance.repos.RecurrentOperationRepo
|
||||
import space.luminic.finance.repos.SpaceRepo
|
||||
import space.luminic.finance.repos.TransactionRepo
|
||||
import java.time.LocalDate
|
||||
|
||||
@Service
|
||||
class RecurrentOperationServiceImpl(
|
||||
private val authService: AuthService,
|
||||
private val spaceRepo: SpaceRepo,
|
||||
private val recurrentOperationRepo: RecurrentOperationRepo,
|
||||
private val categoryService: CategoryService
|
||||
private val categoryService: CategoryService,
|
||||
private val transactionService: TransactionService,
|
||||
private val transactionRepo: TransactionRepo
|
||||
) : RecurrentOperationService {
|
||||
private val logger = LoggerFactory.getLogger(this.javaClass)
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
override fun findBySpaceId(spaceId: Int): List<RecurrentOperation> {
|
||||
val userId = authService.getSecurityUserId()
|
||||
spaceRepo.findSpaceById(spaceId, userId)
|
||||
@@ -26,12 +40,14 @@ class RecurrentOperationServiceImpl(
|
||||
): RecurrentOperation {
|
||||
val userId = authService.getSecurityUserId()
|
||||
spaceRepo.findSpaceById(spaceId, userId)
|
||||
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id) ?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
|
||||
return recurrentOperationRepo.findBySpaceIdAndId(spaceId, id)
|
||||
?: throw NotFoundException("Cannot find recurrent operation with id ${id}")
|
||||
}
|
||||
|
||||
override fun create(spaceId: Int, operation: RecurrentOperationDTO.CreateRecurrentOperationDTO): Int {
|
||||
val userId = authService.getSecurityUserId()
|
||||
val space = spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
|
||||
val space =
|
||||
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Cannot find space with id ${spaceId}")
|
||||
val category = categoryService.getCategory(spaceId, operation.categoryId)
|
||||
val creatingOperation = RecurrentOperation(
|
||||
space = space,
|
||||
@@ -40,14 +56,42 @@ class RecurrentOperationServiceImpl(
|
||||
amount = operation.amount,
|
||||
date = operation.date
|
||||
)
|
||||
return recurrentOperationRepo.create(creatingOperation, userId)
|
||||
|
||||
val createdRecurrentId = recurrentOperationRepo.create(creatingOperation, userId)
|
||||
val transactionsToCreate = mutableListOf<Transaction>()
|
||||
serviceScope.launch {
|
||||
runCatching {
|
||||
val date = LocalDate.now()
|
||||
for (i in 1..12) {
|
||||
transactionsToCreate.add(
|
||||
Transaction(
|
||||
space = space,
|
||||
type = if (category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||
kind = Transaction.TransactionKind.PLANNING,
|
||||
category = category,
|
||||
comment = creatingOperation.name,
|
||||
amount = creatingOperation.amount,
|
||||
date = date.plusMonths(i.toLong()),
|
||||
recurrentId = createdRecurrentId
|
||||
)
|
||||
)
|
||||
}
|
||||
transactionRepo.createBatch(transactionsToCreate, userId)
|
||||
// transactionService.batchCreate(spaceId, transactionsToCreate, userId)
|
||||
}.onFailure {
|
||||
logger.error("Error creating recurring operation", it)
|
||||
}
|
||||
}
|
||||
|
||||
return createdRecurrentId
|
||||
}
|
||||
|
||||
override fun update(spaceId: Int, operationId: Int, operation: RecurrentOperationDTO.UpdateRecurrentOperationDTO) {
|
||||
val userId = authService.getSecurityUserId()
|
||||
spaceRepo.findSpaceById(spaceId, userId)
|
||||
val newCategory = categoryService.getCategory(spaceId, operation.categoryId)
|
||||
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId,operationId ) ?: throw NotFoundException("Cannot find operation with id $operationId")
|
||||
val existingOperation = recurrentOperationRepo.findBySpaceIdAndId(spaceId, operationId)
|
||||
?: throw NotFoundException("Cannot find operation with id $operationId")
|
||||
val updatedOperation = existingOperation.copy(
|
||||
category = newCategory,
|
||||
name = operation.name,
|
||||
@@ -63,4 +107,25 @@ class RecurrentOperationServiceImpl(
|
||||
spaceRepo.findSpaceById(spaceId, userId)
|
||||
recurrentOperationRepo.delete(id)
|
||||
}
|
||||
|
||||
override fun createRecurrentTransactions() {
|
||||
val today = LocalDate.now()
|
||||
val recurrents = recurrentOperationRepo.findByDate(today.dayOfMonth)
|
||||
recurrents.forEach {
|
||||
transactionRepo.create(
|
||||
Transaction(
|
||||
space = it.space,
|
||||
type = if (it.category.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||
kind = Transaction.TransactionKind.PLANNING,
|
||||
category = it.category,
|
||||
comment = it.name,
|
||||
amount = it.amount,
|
||||
date = today.plusMonths(13),
|
||||
recurrentId = it.id
|
||||
), it.createdBy?.id!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
20
src/main/kotlin/space/luminic/finance/services/Scheduler.kt
Normal file
20
src/main/kotlin/space/luminic/finance/services/Scheduler.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package space.luminic.finance.services
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@EnableScheduling
|
||||
@Service
|
||||
class Scheduler(
|
||||
private val recurrentOperationService: RecurrentOperationService
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(Scheduler::class.java)
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
fun createRecurrentAfter13Month() {
|
||||
log.info("Creating recurrent after 13 month")
|
||||
recurrentOperationService.createRecurrentTransactions()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ interface SpaceService {
|
||||
|
||||
fun checkSpace(spaceId: Int): Space
|
||||
fun getSpaces(): List<Space>
|
||||
fun getSpace(id: Int): Space
|
||||
fun getSpace(id: Int, userId: Int?): Space
|
||||
fun createSpace(space: SpaceDTO.CreateSpaceDTO): Int
|
||||
fun updateSpace(spaceId: Int, space: SpaceDTO.UpdateSpaceDTO): Int
|
||||
fun deleteSpace(spaceId: Int)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package space.luminic.finance.services
|
||||
|
||||
import org.springframework.cache.annotation.CacheEvict
|
||||
import org.springframework.cache.annotation.Cacheable
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.luminic.finance.dtos.SpaceDTO
|
||||
@@ -17,7 +14,7 @@ class SpaceServiceImpl(
|
||||
private val categoryService: CategoryService
|
||||
) : SpaceService {
|
||||
override fun checkSpace(spaceId: Int): Space {
|
||||
return getSpace(spaceId)
|
||||
return getSpace(spaceId, null)
|
||||
}
|
||||
|
||||
// @Cacheable(cacheNames = ["spaces"])
|
||||
@@ -27,8 +24,9 @@ class SpaceServiceImpl(
|
||||
return spaces
|
||||
}
|
||||
|
||||
override fun getSpace(id: Int): Space {
|
||||
val user = authService.getSecurityUserId()
|
||||
|
||||
override fun getSpace(id: Int, userId: Int?): Space {
|
||||
val user = userId ?: authService.getSecurityUserId()
|
||||
val space = spaceRepo.findSpaceById(id, user) ?: throw NotFoundException("Space with id $id not found")
|
||||
return space
|
||||
|
||||
@@ -56,7 +54,7 @@ class SpaceServiceImpl(
|
||||
space: SpaceDTO.UpdateSpaceDTO
|
||||
): Int {
|
||||
val userId = authService.getSecurityUserId()
|
||||
val existingSpace = getSpace(spaceId)
|
||||
val existingSpace = getSpace(spaceId, null)
|
||||
val updatedSpace = Space(
|
||||
id = existingSpace.id,
|
||||
name = space.name,
|
||||
|
||||
@@ -11,9 +11,17 @@ interface TransactionService {
|
||||
val dateTo: LocalDate? = null,
|
||||
)
|
||||
|
||||
fun getTransactions(spaceId: Int, filter: TransactionsFilter, sortBy: String, sortDirection: String): List<Transaction>
|
||||
fun getTransactions(
|
||||
spaceId: Int,
|
||||
filter: TransactionsFilter,
|
||||
sortBy: String,
|
||||
sortDirection: String
|
||||
): List<Transaction>
|
||||
|
||||
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
|
||||
fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int
|
||||
fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?)
|
||||
fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int
|
||||
fun deleteTransaction(spaceId: Int, transactionId: Int)
|
||||
fun deleteByRecurrentId(spaceId: Int, recurrentId: Int)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import space.luminic.finance.dtos.TransactionDTO
|
||||
import space.luminic.finance.models.NotFoundException
|
||||
import space.luminic.finance.models.Transaction
|
||||
import space.luminic.finance.repos.TransactionRepo
|
||||
import space.luminic.finance.services.gpt.CategorizeService
|
||||
|
||||
@Service
|
||||
class TransactionServiceImpl(
|
||||
@@ -12,6 +13,7 @@ class TransactionServiceImpl(
|
||||
private val categoryService: CategoryService,
|
||||
private val transactionRepo: TransactionRepo,
|
||||
private val authService: AuthService,
|
||||
private val categorizeService: CategorizeService,
|
||||
) : TransactionService {
|
||||
override fun getTransactions(
|
||||
spaceId: Int,
|
||||
@@ -19,14 +21,15 @@ class TransactionServiceImpl(
|
||||
sortBy: String,
|
||||
sortDirection: String
|
||||
): List<Transaction> {
|
||||
return transactionRepo.findAllBySpaceId(spaceId)
|
||||
val transactions = transactionRepo.findAllBySpaceId(spaceId)
|
||||
return transactions
|
||||
}
|
||||
|
||||
override fun getTransaction(
|
||||
spaceId: Int,
|
||||
transactionId: Int
|
||||
): Transaction {
|
||||
spaceService.getSpace(spaceId)
|
||||
spaceService.getSpace(spaceId, null)
|
||||
return transactionRepo.findBySpaceIdAndId(spaceId, transactionId)
|
||||
?: throw NotFoundException("Transaction with id $transactionId not found")
|
||||
}
|
||||
@@ -36,8 +39,9 @@ class TransactionServiceImpl(
|
||||
transaction: TransactionDTO.CreateTransactionDTO
|
||||
): Int {
|
||||
val userId = authService.getSecurityUserId()
|
||||
val space = spaceService.getSpace(spaceId)
|
||||
val category = categoryService.getCategory(spaceId, transaction.categoryId)
|
||||
val space = spaceService.getSpace(spaceId, null)
|
||||
|
||||
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||
val transaction = Transaction(
|
||||
space = space,
|
||||
type = transaction.type,
|
||||
@@ -47,26 +51,43 @@ class TransactionServiceImpl(
|
||||
amount = transaction.amount,
|
||||
fees = transaction.fees,
|
||||
date = transaction.date,
|
||||
recurrentId = transaction.recurrentId,
|
||||
)
|
||||
return transactionRepo.create(transaction, userId)
|
||||
}
|
||||
|
||||
override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
|
||||
val userId = createdById ?: authService.getSecurityUserId()
|
||||
val space = spaceService.getSpace(spaceId, userId)
|
||||
val transactionsToCreate = mutableListOf<Transaction>()
|
||||
transactions.forEach { transaction ->
|
||||
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||
transactionsToCreate.add(
|
||||
Transaction(
|
||||
space = space,
|
||||
type = transaction.type,
|
||||
kind = transaction.kind,
|
||||
category = category,
|
||||
comment = transaction.comment,
|
||||
amount = transaction.amount,
|
||||
fees = transaction.fees,
|
||||
date = transaction.date,
|
||||
recurrentId = transaction.recurrentId,
|
||||
)
|
||||
)
|
||||
}
|
||||
transactionRepo.createBatch(transactionsToCreate, userId)
|
||||
|
||||
}
|
||||
|
||||
override fun updateTransaction(
|
||||
spaceId: Int,
|
||||
transactionId: Int,
|
||||
transaction: TransactionDTO.UpdateTransactionDTO
|
||||
): Int {
|
||||
val space = spaceService.getSpace(spaceId)
|
||||
val space = spaceService.getSpace(spaceId, null)
|
||||
val existingTransaction = getTransaction(space.id!!, transactionId)
|
||||
val newCategory = categoryService.getCategory(spaceId, transaction.categoryId)
|
||||
// val id: Int,
|
||||
// val type: TransactionType = TransactionType.EXPENSE,
|
||||
// val kind: TransactionKind = TransactionKind.INSTANT,
|
||||
// val category: Int,
|
||||
// val comment: String,
|
||||
// val amount: BigDecimal,
|
||||
// val fees: BigDecimal = BigDecimal.ZERO,
|
||||
// val date: Instant
|
||||
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||
val updatedTransaction = Transaction(
|
||||
id = existingTransaction.id,
|
||||
space = existingTransaction.space,
|
||||
@@ -81,16 +102,24 @@ class TransactionServiceImpl(
|
||||
isDeleted = existingTransaction.isDeleted,
|
||||
isDone = transaction.isDone,
|
||||
createdBy = existingTransaction.createdBy,
|
||||
createdAt = existingTransaction.createdAt
|
||||
|
||||
createdAt = existingTransaction.createdAt,
|
||||
tgChatId = existingTransaction.tgChatId,
|
||||
tgMessageId = existingTransaction.tgMessageId,
|
||||
)
|
||||
if (existingTransaction.category == null && updatedTransaction.category != null) {
|
||||
categorizeService.notifyThatCategorySelected(updatedTransaction)
|
||||
}
|
||||
return transactionRepo.update(updatedTransaction)
|
||||
}
|
||||
|
||||
override fun deleteTransaction(spaceId: Int, transactionId: Int) {
|
||||
val space = spaceService.getSpace(spaceId)
|
||||
val space = spaceService.getSpace(spaceId, null)
|
||||
getTransaction(space.id!!, transactionId)
|
||||
transactionRepo.delete(transactionId)
|
||||
}
|
||||
|
||||
override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class UserService(val userRepo: UserRepo) {
|
||||
}
|
||||
|
||||
fun getUserByTelegramId(telegramId: Long): User {
|
||||
return userRepo.findByTgId(telegramId.toString())?: throw NotFoundException("User with telegramId: $telegramId not found")
|
||||
return userRepo.findByTgId(telegramId)?: throw NotFoundException("User with telegramId: $telegramId not found")
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class CategorizeBotScheduler(
|
||||
private val seeder: CategoryJobSeeder,
|
||||
private val picker: CategoryJobRepo,
|
||||
private val service: CategorizeService
|
||||
) {
|
||||
|
||||
|
||||
@Scheduled(cron = "* * * * * *")
|
||||
fun work() {
|
||||
val jobs = picker.pickBatch(limit = 50)
|
||||
if (jobs.isEmpty()) return
|
||||
service.processBatch(jobs)
|
||||
}
|
||||
|
||||
@Scheduled(cron = "* * * * * *")
|
||||
fun createJob(){
|
||||
seeder.seedMissing()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
import com.github.kotlintelegrambot.Bot
|
||||
import com.github.kotlintelegrambot.entities.ChatId
|
||||
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
|
||||
import com.github.kotlintelegrambot.entities.Message
|
||||
import com.github.kotlintelegrambot.entities.MessageId
|
||||
import com.github.kotlintelegrambot.entities.ParseMode
|
||||
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
|
||||
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
|
||||
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||
import com.github.kotlintelegrambot.types.TelegramBotResult
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.luminic.finance.models.Transaction
|
||||
import space.luminic.finance.repos.CategoryRepo
|
||||
import space.luminic.finance.repos.TransactionRepo
|
||||
|
||||
enum class JobStatus { NEW, PROCESSING, DONE, FAILED }
|
||||
|
||||
data class CategoryResult(val categoryId: Int)
|
||||
|
||||
|
||||
@Service
|
||||
class CategorizeService(
|
||||
private val transactionRepo: TransactionRepo,
|
||||
@Qualifier("dsCategorizationService") private val gpt: GptClient,
|
||||
@Value("\${app.categorize.parallel:4}") private val parallel: Int,
|
||||
private val categoriesRepo: CategoryRepo,
|
||||
private val categoryJobRepo: CategoryJobRepo,
|
||||
private val bot: Bot
|
||||
) {
|
||||
private val exec = java.util.concurrent.Executors.newFixedThreadPool(parallel)
|
||||
|
||||
fun processBatch(jobs: List<CategoryJob>) {
|
||||
jobs.forEach { job ->
|
||||
exec.submit {
|
||||
runCatching {
|
||||
val tx = transactionRepo.findBySpaceIdAndId(job.spaceId, job.txId)
|
||||
?: throw IllegalArgumentException("Transaction ${job.txId} not found")
|
||||
val res = gpt.suggestCategory(
|
||||
tx,
|
||||
categoriesRepo.findBySpaceId(job.spaceId)
|
||||
) // тут твой вызов GPT
|
||||
var unsuccessMessage: TelegramBotResult<Message>? = null
|
||||
|
||||
if (res.categoryId == 0) {
|
||||
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||
bot.setMessageReaction(
|
||||
ChatId.fromId(tx.tgChatId),
|
||||
tx.tgMessageId,
|
||||
listOf(ReactionType.Emoji("💔")),
|
||||
isBig = false
|
||||
)
|
||||
unsuccessMessage = bot.sendMessage(
|
||||
ChatId.fromId(tx.tgChatId),
|
||||
replyToMessageId = tx.tgMessageId,
|
||||
text = "К сожалению, мы не смогли распознать категорию.\n\nПопробуйте выставить ее самостоятельно.",
|
||||
replyMarkup = InlineKeyboardMarkup.create(
|
||||
listOf(
|
||||
InlineKeyboardButton.WebApp(
|
||||
"Открыть в WebApp",
|
||||
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
transactionRepo.setCategory(job.txId, res.categoryId)
|
||||
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||
bot.setMessageReaction(
|
||||
ChatId.fromId(tx.tgChatId),
|
||||
tx.tgMessageId,
|
||||
listOf(ReactionType.Emoji("👌")),
|
||||
isBig = false
|
||||
)
|
||||
val category = categoriesRepo.findBySpaceIdAndId(job.spaceId, res.categoryId)
|
||||
if (category != null) {
|
||||
bot.sendMessage(
|
||||
ChatId.fromId(tx.tgChatId),
|
||||
replyToMessageId = tx.tgMessageId,
|
||||
text = "Определили категорию: <b>${category.name}</b>.\n\nЕсли это не так, исправьте это в WebApp.",
|
||||
replyMarkup = InlineKeyboardMarkup.create(
|
||||
listOf(
|
||||
InlineKeyboardButton.WebApp(
|
||||
"Открыть в WebApp",
|
||||
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||
)
|
||||
)
|
||||
),
|
||||
parseMode = ParseMode.HTML
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unsuccessMessage != null) {
|
||||
categoryJobRepo.successJob(
|
||||
job.id,
|
||||
unsuccessMessage.get().chat.id,
|
||||
unsuccessMessage.get().messageId
|
||||
)
|
||||
} else {
|
||||
categoryJobRepo.successJob(job.id)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
print(e.localizedMessage)
|
||||
categoryJobRepo.failJob(job.id, e.localizedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyThatCategorySelected(tx: Transaction) {
|
||||
val job = categoryJobRepo.getJobByTxId(tx.id!!)
|
||||
|
||||
job?.let {
|
||||
if (tx.tgChatId != null && tx.tgMessageId != null) {
|
||||
bot.setMessageReaction(
|
||||
ChatId.fromId(tx.tgChatId),
|
||||
tx.tgMessageId,
|
||||
listOf(ReactionType.Emoji("👌"))
|
||||
)
|
||||
}
|
||||
if (it.chatId != null && it.messageId != null) {
|
||||
bot.editMessageText(
|
||||
ChatId.fromId(it.chatId),
|
||||
messageId = it.messageId,
|
||||
text = "Выбрана: <b>${tx.category!!.name}</b>.\n\nЕсли это не так, измените это в WebApp.",
|
||||
replyMarkup = InlineKeyboardMarkup.create(
|
||||
listOf(
|
||||
InlineKeyboardButton.WebApp(
|
||||
"Открыть в WebApp",
|
||||
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
|
||||
)
|
||||
)
|
||||
),
|
||||
parseMode = ParseMode.HTML
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
import com.github.kotlintelegrambot.Bot
|
||||
import com.github.kotlintelegrambot.entities.ChatId
|
||||
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class CategoryJob(
|
||||
val id: Long,
|
||||
val spaceId: Int,
|
||||
val txId: Int,
|
||||
val attempts: Int,
|
||||
val chatId: Long? = null,
|
||||
val messageId: Long? = null
|
||||
)
|
||||
|
||||
@Repository
|
||||
class CategoryJobRepo(
|
||||
private val np: NamedParameterJdbcTemplate,
|
||||
private val bot: Bot
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun pickBatch(limit: Int = 50): List<CategoryJob> {
|
||||
val selectSql = """
|
||||
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id
|
||||
FROM finance.category_jobs cj
|
||||
JOIN finance.transactions t on cj.tx_id = t.id
|
||||
JOIN finance.spaces s on t.space_id = s.id
|
||||
WHERE status IN ('NEW', 'FAILED')
|
||||
ORDER BY cj.created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :limit
|
||||
""".trimIndent()
|
||||
|
||||
val jobs = np.query(selectSql, mapOf("limit" to limit)) { rs, _ ->
|
||||
CategoryJob(
|
||||
id = rs.getLong("cj_id"),
|
||||
spaceId = rs.getInt("s_id"),
|
||||
txId = rs.getInt("cj_tx_id"),
|
||||
attempts = rs.getInt("cj_attempts")
|
||||
)
|
||||
}
|
||||
|
||||
if (jobs.isNotEmpty()) {
|
||||
val updateSql = """
|
||||
UPDATE finance.category_jobs
|
||||
SET status = 'PROCESSING',
|
||||
attempts = attempts + 1,
|
||||
started_at = NOW()
|
||||
WHERE id IN (:ids)
|
||||
""".trimIndent()
|
||||
np.update(updateSql, mapOf("ids" to jobs.map { it.id }))
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun successJob(id: Long, chatId: Long? = null, messageId: Long? = null) {
|
||||
val sql =
|
||||
"""UPDATE finance.category_jobs SET status = 'DONE', finished_at = now(), tg_chat_id = :chatId, tg_message_id = :messageId WHERE id = :id """.trimIndent()
|
||||
np.update(sql, mapOf("id" to id, "chatId" to chatId, "messageId" to messageId))
|
||||
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun failJob(id: Long, errorMessage: String?) {
|
||||
val sql =
|
||||
"""UPDATE finance.category_jobs SET status = 'FAILED', last_error = :message WHERE id = :id """.trimIndent()
|
||||
np.update(sql, mapOf("id" to id, "errorMessage" to errorMessage))
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun getJobByTxId(txId: Int): CategoryJob? {
|
||||
val selectSql = """
|
||||
SELECT cj.id as cj_id, cj.tx_id as cj_tx_id, cj.attempts as cj_attempts, s.id as s_id, cj.tg_chat_id as cj_chat_id, cj.tg_message_id as cj_message_id
|
||||
FROM finance.category_jobs cj
|
||||
JOIN finance.transactions t on cj.tx_id = t.id
|
||||
JOIN finance.spaces s on t.space_id = s.id
|
||||
WHERE cj.tx_id = :txId
|
||||
""".trimIndent()
|
||||
val jobs = np.query(selectSql, mapOf("txId" to txId), { rs, _ ->
|
||||
CategoryJob(
|
||||
id = rs.getLong("cj_id"),
|
||||
spaceId = rs.getInt("s_id"),
|
||||
txId = rs.getInt("cj_tx_id"),
|
||||
attempts = rs.getInt("cj_attempts"),
|
||||
chatId = rs.getLong("cj_chat_id"),
|
||||
messageId = rs.getLong("cj_message_id")
|
||||
)
|
||||
})
|
||||
return jobs.firstOrNull()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Repository
|
||||
class CategoryJobSeeder(private val np: NamedParameterJdbcTemplate) {
|
||||
|
||||
/**
|
||||
* Создаёт задачи для всех транзакций без категории.
|
||||
* Ограничь лимит, чтобы не захлестнуть очередь.
|
||||
*/
|
||||
@Transactional
|
||||
fun seedMissing(limit: Int = 1000) : Int {
|
||||
val sql = """
|
||||
INSERT INTO finance.category_jobs (tx_id)
|
||||
SELECT t.id
|
||||
FROM finance.transactions t
|
||||
WHERE t.category_id IS NULL
|
||||
AND t.is_deleted = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM finance.category_jobs j WHERE j.tx_id = t.id
|
||||
)
|
||||
ORDER BY t.date DESC
|
||||
LIMIT :limit
|
||||
ON CONFLICT (tx_id) DO NOTHING
|
||||
""".trimIndent()
|
||||
return np.update(sql, mapOf("limit" to limit))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
|
||||
import okhttp3.*
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.models.Category
|
||||
import space.luminic.finance.models.Transaction
|
||||
|
||||
|
||||
@Service("dsCategorizationService")
|
||||
class DeepSeekCategorizationService(
|
||||
@Value("\${ds.api_key}") private val apiKey: String,
|
||||
) : GptClient {
|
||||
|
||||
private val endpoint = "https://api.deepseek.com/v1"
|
||||
private val mapper = jacksonObjectMapper()
|
||||
private val client = OkHttpClient()
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
||||
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
|
||||
val txInfo = """
|
||||
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
|
||||
""".trimIndent()
|
||||
val prompt = """
|
||||
Пользователь имеет следующие категории:
|
||||
$catList
|
||||
|
||||
Задача:
|
||||
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
|
||||
2. Верните ответ в формате: "ID категории", например "3".
|
||||
3. Если ни одна категория из списка не подходит, верните: "0".
|
||||
|
||||
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
|
||||
""".trimIndent()
|
||||
|
||||
val body = mapOf(
|
||||
"model" to "deepseek-chat",
|
||||
"messages" to listOf(
|
||||
mapOf("role" to "assistant", "content" to prompt),
|
||||
mapOf("role" to "user", "content" to txInfo)
|
||||
)
|
||||
)
|
||||
val jsonBody = mapper.writeValueAsString(body)
|
||||
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${endpoint}/chat/completions")
|
||||
.addHeader("Authorization", "Bearer $apiKey")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
println(request)
|
||||
logger.info(request.toString())
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
|
||||
|
||||
val bodyStr = response.body?.string().orEmpty()
|
||||
|
||||
// Берём content из choices[0].message.content
|
||||
val root = mapper.readTree(bodyStr)
|
||||
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
|
||||
?: error("No choices[0].message.content in response")
|
||||
|
||||
// Парсим "ID – Название (вероятность)"
|
||||
// val regex = Regex("""^\s*(\d+)\s*[–-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
|
||||
// val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
|
||||
|
||||
// val (idStr, name, confStr) = match.destructured
|
||||
val idStr = text
|
||||
return CategorySuggestion(idStr.toInt(), )
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
import space.luminic.finance.models.Category
|
||||
import space.luminic.finance.models.Transaction
|
||||
|
||||
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
|
||||
|
||||
interface GptClient {
|
||||
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package space.luminic.finance.services.gpt
|
||||
|
||||
|
||||
import okhttp3.*
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.models.Category
|
||||
import space.luminic.finance.models.Transaction
|
||||
|
||||
@Service("qwenCategorizationService")
|
||||
class QwenCategorizationService(
|
||||
@Value("\${qwen.api_key}") private val apiKey: String,
|
||||
) : GptClient {
|
||||
|
||||
private val endpoint = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
private val mapper = jacksonObjectMapper()
|
||||
private val client = OkHttpClient()
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
||||
val catList = categories.joinToString("\n") { "- ${it.id}: ${it.name}" }
|
||||
val txInfo = """
|
||||
{ \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
|
||||
""".trimIndent()
|
||||
val prompt = """
|
||||
Пользователь имеет следующие категории:
|
||||
$catList
|
||||
|
||||
Задача:
|
||||
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
|
||||
2. Верните ответ в формате: "ID категории – имя категории (вероятность)", например "3 – Продукты (0.87)".
|
||||
3. Если ни одна категория из списка не подходит, верните: "0 – Другое (вероятность)".
|
||||
|
||||
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.
|
||||
""".trimIndent()
|
||||
|
||||
val body = mapOf(
|
||||
"model" to "qwen-plus",
|
||||
"messages" to listOf(
|
||||
mapOf("role" to "assistant", "content" to prompt),
|
||||
mapOf("role" to "user", "content" to txInfo)
|
||||
)
|
||||
)
|
||||
val jsonBody = mapper.writeValueAsString(body)
|
||||
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${endpoint}/chat/completions")
|
||||
.addHeader("Authorization", "Bearer $apiKey")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
println(request)
|
||||
logger.info(request.toString())
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}")
|
||||
|
||||
val bodyStr = response.body?.string().orEmpty()
|
||||
|
||||
// Берём content из choices[0].message.content
|
||||
val root = mapper.readTree(bodyStr)
|
||||
val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText()
|
||||
?: error("No choices[0].message.content in response")
|
||||
|
||||
// Парсим "ID – Название (вероятность)"
|
||||
val regex = Regex("""^\s*(\d+)\s*[–-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""")
|
||||
val match = regex.find(text.trim()) ?: error("Bad format: '$text'")
|
||||
|
||||
val (idStr, name, confStr) = match.destructured
|
||||
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package space.luminic.finance.services.telegram
|
||||
|
||||
import com.github.kotlintelegrambot.Bot
|
||||
import com.github.kotlintelegrambot.dispatch
|
||||
import com.github.kotlintelegrambot.dispatcher.callbackQuery
|
||||
import com.github.kotlintelegrambot.dispatcher.command
|
||||
import com.github.kotlintelegrambot.dispatcher.message
|
||||
import com.github.kotlintelegrambot.entities.ChatId
|
||||
import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup
|
||||
import com.github.kotlintelegrambot.entities.ParseMode
|
||||
import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton
|
||||
import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo
|
||||
import com.github.kotlintelegrambot.entities.reaction.ReactionType
|
||||
import com.github.kotlintelegrambot.extensions.filters.Filter
|
||||
import com.github.kotlintelegrambot.logging.LogLevel
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import space.luminic.finance.dtos.TransactionDTO
|
||||
import space.luminic.finance.models.NotFoundException
|
||||
import space.luminic.finance.models.State
|
||||
import space.luminic.finance.models.Transaction
|
||||
import space.luminic.finance.models.User
|
||||
import space.luminic.finance.repos.BotRepo
|
||||
import space.luminic.finance.services.UserService
|
||||
import java.time.LocalDate
|
||||
|
||||
@Service
|
||||
class BotService(
|
||||
@Value("\${telegram.bot.token}") private val botToken: String,
|
||||
private val userService: UserService,
|
||||
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
|
||||
private val botRepo: BotRepo,
|
||||
@Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService
|
||||
) {
|
||||
|
||||
|
||||
private fun buildSpaceSelector(userId: Int): InlineKeyboardMarkup {
|
||||
val spaces = spaceService.getSpaces(userId)
|
||||
|
||||
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
|
||||
val row = mutableListOf<InlineKeyboardButton>()
|
||||
if (spaces.isNotEmpty()) {
|
||||
for ((index, space) in spaces.withIndex()) {
|
||||
val button =
|
||||
InlineKeyboardButton.CallbackData(text = space.name, callbackData = "select_space_${space.id}")
|
||||
|
||||
row.add(button)
|
||||
|
||||
// Если 2 кнопки в строке — отправляем строку и очищаем
|
||||
if (row.size == 2) {
|
||||
keyboard.add(ArrayList(row))
|
||||
row.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Если осталась 1 кнопка — добавляем последнюю строку
|
||||
if (row.isNotEmpty()) {
|
||||
keyboard.add(ArrayList(row))
|
||||
}
|
||||
} else {
|
||||
row.add(InlineKeyboardButton.CallbackData("Создать пространство!", callbackData = "create_space"))
|
||||
keyboard.add(ArrayList(row))
|
||||
}
|
||||
|
||||
return InlineKeyboardMarkup.Companion.create(keyboard)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun selectSpace(tgUserId: Long, selectedSpaceId: Int) {
|
||||
val user = userService.getUserByTelegramId(tgUserId)
|
||||
botRepo.setState(
|
||||
user.id!!, State.StateCode.SPACE_SELECTED, mapOf(
|
||||
"selected_space" to selectedSpaceId.toString(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRegister() {
|
||||
|
||||
}
|
||||
|
||||
private fun buildMenu(tgUserId: Long): InlineKeyboardMarkup {
|
||||
val user = userService.getUserByTelegramId(tgUserId)
|
||||
val userId = requireNotNull(user.id) { "User must have id" }
|
||||
|
||||
val state = botRepo.getState(tgUserId)
|
||||
|
||||
val spaceId = state?.data?.get("selected_space")?.toIntOrNull()
|
||||
val space = spaceId?.let { id -> spaceService.getSpace(spaceId, userId) }
|
||||
|
||||
val keyboard = mutableListOf<List<InlineKeyboardButton>>()
|
||||
|
||||
// Кнопка с названием выбранного space (или плейсхолдером)
|
||||
keyboard.add(
|
||||
listOf(
|
||||
InlineKeyboardButton.CallbackData(
|
||||
text = space?.name ?: "Select space",
|
||||
"select_space"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Если нужен второй баттон — сделай другой смысл/текст; иначе этот блок можно убрать
|
||||
keyboard.add(
|
||||
listOf(
|
||||
InlineKeyboardButton.WebApp(
|
||||
text = "Открыть WebApp",
|
||||
webApp = WebAppInfo(url = "https://app.luminic.space")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup.Companion.create(keyboard)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun bot(): Bot {
|
||||
val bot = com.github.kotlintelegrambot.bot {
|
||||
logLevel = LogLevel.None
|
||||
token = botToken
|
||||
dispatch {
|
||||
message(Filter.Text) {
|
||||
val fromId = message.from?.id ?: throw IllegalArgumentException("user is empty")
|
||||
val user = userService.getUserByTelegramId(fromId)
|
||||
val state = botRepo.getState(message.from?.id ?: throw IllegalArgumentException("user is empty"))
|
||||
when (state?.state) {
|
||||
State.StateCode.SPACE_SELECTED -> {
|
||||
try {
|
||||
val parts = message.text!!.trim().split(" ", limit = 2)
|
||||
if (parts.isEmpty()) {
|
||||
bot.sendMessage(
|
||||
chatId = ChatId.fromId(message.chat.id),
|
||||
text = "Введите сумму и комментарий, например: `250 обед`",
|
||||
parseMode = ParseMode.MARKDOWN
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
val amount = parts[0].toIntOrNull()
|
||||
?: throw IllegalArgumentException("Сумма транзакции не число!")
|
||||
if (amount <= 0) {
|
||||
throw IllegalArgumentException("Сумма не может быть меньше 1.")
|
||||
}
|
||||
val comment = parts.getOrNull(1)?.trim().orEmpty()
|
||||
if (comment.isEmpty()) throw IllegalArgumentException("Комментарий не может быть пустым.")
|
||||
|
||||
|
||||
// bot.sendMessage(
|
||||
// chatId = ChatId.fromId(message.chat.id),
|
||||
// text = "Принято: сумма = $amount, комментарий = \"$comment\""
|
||||
// )
|
||||
|
||||
try {
|
||||
transactionService.createTransaction(
|
||||
state.data["selected_space"]?.toInt()
|
||||
?: throw IllegalArgumentException("selected space is empty"),
|
||||
user.id!!,
|
||||
TransactionDTO.CreateTransactionDTO(
|
||||
Transaction.TransactionType.EXPENSE,
|
||||
Transaction.TransactionKind.INSTANT,
|
||||
comment = comment,
|
||||
amount = amount.toBigDecimal(),
|
||||
date = LocalDate.now(),
|
||||
),
|
||||
message.chat.id,
|
||||
message.messageId
|
||||
)
|
||||
bot.setMessageReaction(
|
||||
chatId = ChatId.fromId(message.chat.id),
|
||||
messageId = message.messageId,
|
||||
reaction = listOf(ReactionType.Emoji("🤝")),
|
||||
isBig = false
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
bot.sendMessage(
|
||||
ChatId.Companion.fromId(message.chat.id),
|
||||
text = "Кажется у вас не выбран Space",
|
||||
replyMarkup = buildSpaceSelector(user.id!!)
|
||||
)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
bot.sendMessage(
|
||||
chatId = ChatId.Companion.fromId(message.chat.id),
|
||||
text = "Ошибка: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
callbackQuery {
|
||||
if (callbackQuery.data.startsWith("select_space_")) {
|
||||
val spaceId = callbackQuery.data.substringAfter("select_space_").toInt()
|
||||
println(spaceId)
|
||||
try {
|
||||
selectSpace(callbackQuery.from.id, spaceId)
|
||||
bot.editMessageText(
|
||||
chatId = ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||
messageId = callbackQuery.message!!.messageId,
|
||||
text = "Успешно!\n\nМы готовы принимать Ваши транзакции.\n\nПросто пишите их в формате:\n\n <i>сумма комментарий</i>\n\n <b>Первой обязательно должна быть сумма!</b>",
|
||||
parseMode = ParseMode.HTML,
|
||||
replyMarkup = buildMenu(callbackQuery.from.id)
|
||||
)
|
||||
} catch (e: NotFoundException) {
|
||||
e.printStackTrace()
|
||||
bot.sendMessage(
|
||||
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||
text = "Мы кажется не знакомы"
|
||||
)
|
||||
}
|
||||
|
||||
} else if (callbackQuery.data.equals("select_space", ignoreCase = true)) {
|
||||
bot.editMessageText(
|
||||
ChatId.Companion.fromId(callbackQuery.message!!.chat.id),
|
||||
callbackQuery.message!!.messageId,
|
||||
text = "Выберите новое пространство",
|
||||
replyMarkup = buildSpaceSelector(
|
||||
userService.getUserByTelegramId(callbackQuery.from.id).id!!
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
command("start") {
|
||||
val user: User
|
||||
try {
|
||||
user = userService.getUserByTelegramId(
|
||||
message.from?.id ?: throw IllegalArgumentException("User not found")
|
||||
)
|
||||
bot.sendMessage(
|
||||
ChatId.Companion.fromId(message.chat.id),
|
||||
text = "Привет!\n\nРады тебя снова видеть!\n\nНачнем с выбора пространства:",
|
||||
replyMarkup = buildSpaceSelector(user.id!!)
|
||||
)
|
||||
|
||||
} catch (e: NotFoundException) {
|
||||
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
|
||||
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Давайте зарегистрируемся? ")
|
||||
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
bot.startPolling()
|
||||
return bot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package space.luminic.finance.services.telegram
|
||||
|
||||
import space.luminic.finance.models.Space
|
||||
|
||||
interface SpaceService {
|
||||
fun getSpaces(userId: Int): List<Space>
|
||||
fun getSpace(spaceId: Int, userId: Int): Space?
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package space.luminic.finance.services.telegram
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.models.NotFoundException
|
||||
import space.luminic.finance.models.Space
|
||||
import space.luminic.finance.repos.SpaceRepo
|
||||
|
||||
@Service("spaceServiceTelegram")
|
||||
class SpaceServiceImpl(
|
||||
private val spaceRepo: SpaceRepo
|
||||
) : SpaceService {
|
||||
override fun getSpaces(userId: Int): List<Space> {
|
||||
val spaces = spaceRepo.findSpacesAvailableForUser(userId)
|
||||
return spaces
|
||||
}
|
||||
|
||||
override fun getSpace(spaceId: Int, userId: Int): Space? {
|
||||
val space =
|
||||
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found")
|
||||
return space
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package space.luminic.finance.services.telegram
|
||||
|
||||
import space.luminic.finance.dtos.TransactionDTO
|
||||
|
||||
interface TransactionService {
|
||||
|
||||
fun createTransaction(spaceId: Int, userId: Int, transaction: TransactionDTO.CreateTransactionDTO, chatId: Long, messageId: Long ): Int
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package space.luminic.finance.services.telegram
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.stereotype.Service
|
||||
import space.luminic.finance.dtos.TransactionDTO
|
||||
import space.luminic.finance.models.Transaction
|
||||
import space.luminic.finance.repos.TransactionRepo
|
||||
import space.luminic.finance.services.CategoryServiceImpl
|
||||
|
||||
@Service("transactionsServiceTelegram")
|
||||
class TransactionsServiceImpl(
|
||||
private val transactionRepo: TransactionRepo,
|
||||
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
|
||||
private val categoryService: CategoryServiceImpl
|
||||
): TransactionService {
|
||||
|
||||
override fun createTransaction(
|
||||
spaceId: Int,
|
||||
userId: Int,
|
||||
transaction: TransactionDTO.CreateTransactionDTO,
|
||||
chatId: Long,
|
||||
messageId: Long
|
||||
): Int {
|
||||
val space = spaceService.getSpace(spaceId, userId)
|
||||
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
||||
val transaction = Transaction(
|
||||
space = space,
|
||||
type = transaction.type,
|
||||
kind = transaction.kind,
|
||||
category = category,
|
||||
comment = transaction.comment,
|
||||
amount = transaction.amount,
|
||||
fees = transaction.fees,
|
||||
date = transaction.date,
|
||||
tgChatId = chatId,
|
||||
tgMessageId = messageId,
|
||||
)
|
||||
print(transaction)
|
||||
return transactionRepo.create(transaction, userId)
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -9,16 +9,16 @@ logging.level.org.springframework.security = DEBUG
|
||||
#logging.level.org.springframework.data.mongodb.code = DEBUG
|
||||
logging.level.org.springframework.web.reactive=DEBUG
|
||||
logging.level.org.mongodb.driver.protocol.command = DEBUG
|
||||
logging.level.org.springframework.jdbc.core=DEBUG
|
||||
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE
|
||||
logging.level.org.springframework.jdbc=DEBUG
|
||||
logging.level.org.springframework.jdbc.datasource=DEBUG
|
||||
logging.level.org.springframework.jdbc.support=DEBUG
|
||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||
logging.level.org.springframework.jdbc.core=INFO
|
||||
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
|
||||
logging.level.org.springframework.jdbc=INFO
|
||||
logging.level.org.springframework.jdbc.datasource=INFO
|
||||
logging.level.org.springframework.jdbc.support=INFO
|
||||
|
||||
management.endpoints.web.exposure.include=*
|
||||
management.endpoint.health.show-details=always
|
||||
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
|
||||
# vector test
|
||||
telegram.bot.token=8127199836:AAEPepyKDAf8PvFpw-fpxBXUuPdx_LS20fI
|
||||
nlp.address=http://127.0.0.1:8000
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
spring.application.name=budger-app
|
||||
|
||||
|
||||
|
||||
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100
|
||||
|
||||
|
||||
logging.level.org.springframework.web=INFO
|
||||
logging.level.org.springframework.data = INFO
|
||||
@@ -13,15 +7,12 @@ logging.level.org.springframework.data.mongodb.code = INFO
|
||||
logging.level.org.springframework.web.reactive=INFO
|
||||
logging.level.org.mongodb.driver.protocol.command = INFO
|
||||
|
||||
#management.endpoints.web.exposure.include=*
|
||||
#management.endpoint.metrics.access=read_only
|
||||
|
||||
|
||||
telegram.bot.token = 6662300972:AAFXjk_h0AUCy4bORC12UcdXbYnh2QSVKAY
|
||||
telegram.bot.token = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks
|
||||
nlp.address=https://nlp.luminic.space
|
||||
|
||||
|
||||
|
||||
#spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db
|
||||
spring.datasource.url=jdbc:postgresql://213.226.71.138:5432/luminic-space-db
|
||||
spring.datasource.username=luminicspace
|
||||
spring.datasource.password=LS1q2w3e4r!
|
||||
@@ -1,6 +1,6 @@
|
||||
spring.application.name=budger-app
|
||||
|
||||
server.port=8082
|
||||
server.port=8089
|
||||
server.servlet.context-path=/api
|
||||
#spring.webflux.base-path=/api
|
||||
|
||||
@@ -17,13 +17,16 @@ spring.servlet.multipart.max-request-size=10MB
|
||||
storage.location: static
|
||||
|
||||
spring.jackson.default-property-inclusion=non_null
|
||||
# Expose prometheus, health, and info endpoints
|
||||
#management.endpoints.web.exposure.include=prometheus,health,info
|
||||
management.endpoints.web.exposure.include=*
|
||||
management.endpoints.web.exposure.include=health,info,prometheus
|
||||
|
||||
# Enable Prometheus metrics export
|
||||
#management.endpoint.prometheus.access=unrestricted
|
||||
management.endpoint.prometheus.enabled=true
|
||||
management.prometheus.metrics.export.enabled=true
|
||||
|
||||
management.metrics.tags.application=luminic-app
|
||||
|
||||
|
||||
|
||||
telegram.bot.username = expenses_diary_bot
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
@@ -31,3 +34,5 @@ spring.flyway.baseline-on-migrate= false
|
||||
spring.flyway.schemas=finance
|
||||
spring.jpa.properties.hibernate.default_schema=finance
|
||||
spring.jpa.properties.hibernate.default_batch_fetch_size=50
|
||||
qwen.api_key=sk-991942d15b424cc89513498bb2946045
|
||||
ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2
|
||||
3
src/main/resources/db/migration/V22__.sql
Normal file
3
src/main/resources/db/migration/V22__.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
alter table finance.users
|
||||
add column photo_url varchar null,
|
||||
alter column tg_id set data type bigint USING tg_id::bigint;;
|
||||
2
src/main/resources/db/migration/V23__.sql
Normal file
2
src/main/resources/db/migration/V23__.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE finance.users
|
||||
ADD CONSTRAINT uq_users_username UNIQUE (username);
|
||||
21
src/main/resources/db/migration/V24__.sql
Normal file
21
src/main/resources/db/migration/V24__.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
create table if not exists finance.bot_states
|
||||
(
|
||||
user_id integer primary key,
|
||||
state_code varchar not null
|
||||
);
|
||||
|
||||
create table if not exists finance.bot_states_data
|
||||
(
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
user_id integer not null,
|
||||
data_code varchar(255) not null,
|
||||
data_value varchar(255) not null,
|
||||
CONSTRAINT pk_bot_states_data PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
ALTER TABLE finance.bot_states
|
||||
ADD CONSTRAINT FK_STATE_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);
|
||||
|
||||
ALTER TABLE finance.bot_states
|
||||
ADD CONSTRAINT FK_STATE_DATA_ON_USER FOREIGN KEY (user_id) REFERENCES finance.users (id);
|
||||
|
||||
3
src/main/resources/db/migration/V25__.sql
Normal file
3
src/main/resources/db/migration/V25__.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
|
||||
ALTER TABLE finance.bot_states_data
|
||||
ADD CONSTRAINT ux_bot_states_data_user_code UNIQUE (user_id, data_code);
|
||||
3
src/main/resources/db/migration/V26__.sql
Normal file
3
src/main/resources/db/migration/V26__.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
|
||||
ALTER TABLE finance.transactions
|
||||
ALTER COLUMN category_id DROP NOT NULL;
|
||||
19
src/main/resources/db/migration/V27__.sql
Normal file
19
src/main/resources/db/migration/V27__.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Очередь классификации категорий
|
||||
CREATE TABLE IF NOT EXISTS finance.category_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tx_id INT NOT NULL UNIQUE, -- одна задача на транзакцию
|
||||
status TEXT NOT NULL DEFAULT 'NEW', -- NEW | PROCESSING | DONE | FAILED
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
-- Быстрые выборки по статусу
|
||||
CREATE INDEX IF NOT EXISTS ix_category_jobs_status ON finance.category_jobs(status);
|
||||
|
||||
-- (опционально) защита от «зависших» задач
|
||||
CREATE INDEX IF NOT EXISTS ix_category_jobs_processing_time
|
||||
ON finance.category_jobs(started_at)
|
||||
WHERE status = 'PROCESSING';
|
||||
3
src/main/resources/db/migration/V28__.sql
Normal file
3
src/main/resources/db/migration/V28__.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
alter table finance.transactions
|
||||
add column tg_chat_id bigint null,
|
||||
add column tg_message_id bigint null;
|
||||
3
src/main/resources/db/migration/V29__.sql
Normal file
3
src/main/resources/db/migration/V29__.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
alter table finance.category_jobs
|
||||
add column tg_chat_id bigint null,
|
||||
add column tg_message_id bigint null;
|
||||
5
src/main/resources/db/migration/V30__.sql
Normal file
5
src/main/resources/db/migration/V30__.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE finance.recurrent_operations
|
||||
ADD CONSTRAINT recurrent_operations_pk PRIMARY KEY (id);
|
||||
alter table finance.transactions
|
||||
add column recurrent_id integer null,
|
||||
ADD CONSTRAINT FK_RECURRENTS FOREIGN KEY (recurrent_id) REFERENCES finance.recurrent_operations (id);
|
||||
Reference in New Issue
Block a user