28 Commits

Author SHA1 Message Date
xds
12afd1f90e recurrents 2025-11-17 15:02:47 +03:00
xds
d0cae182b7 build 2025-10-31 19:28:59 +03:00
xds
0f02b53bc0 build 2025-10-31 18:46:16 +03:00
xds
b08ab909c8 build 2025-10-31 18:45:28 +03:00
xds
a65f46aff3 build 2025-10-31 18:33:11 +03:00
xds
036ad00795 build 2025-10-31 18:29:31 +03:00
xds
1c3605623e build 2025-10-31 18:24:12 +03:00
xds
aaa12fcb86 build 2025-10-31 17:53:39 +03:00
xds
cef82c483f build 2025-10-31 17:46:35 +03:00
xds
e83e3a2b65 build 2025-10-31 17:40:57 +03:00
xds
c68e6afb8a build 2025-10-31 17:38:48 +03:00
xds
10b7c730ad build 2025-10-31 17:33:49 +03:00
xds
a0024def2e tg login 2025-10-31 17:13:10 +03:00
xds
d2458633db tg login 2025-10-31 17:11:40 +03:00
xds
5b9d2366db init 2025-10-31 16:26:24 +03:00
xds
a30eb52f3f init 2025-10-31 16:16:36 +03:00
xds
175c86d787 init 2025-10-31 16:14:56 +03:00
xds
3541528e77 init 2025-10-31 16:12:01 +03:00
xds
62fe88abdf init 2025-10-31 16:10:59 +03:00
xds
61bfdf273a init 2025-10-31 16:10:30 +03:00
xds
4a17b4f528 init 2025-10-31 16:09:31 +03:00
xds
50607a8c42 init 2025-10-31 15:53:26 +03:00
xds
3bb8c33094 init 2025-10-31 15:50:56 +03:00
xds
1bc5932793 init 2025-10-31 15:48:55 +03:00
xds
8d1b0f2a3c init 2025-10-31 15:46:49 +03:00
xds
224fe18639 init 2025-10-31 15:40:45 +03:00
xds
d32062a485 init 2025-10-31 15:40:10 +03:00
0b54384258 Merge pull request 'init' (#1) from sql into main
Reviewed-on: #1
2025-10-31 15:38:34 +03:00
64 changed files with 1958 additions and 149 deletions

View File

@@ -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"]

View File

@@ -29,13 +29,16 @@ configurations {
repositories {
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation ("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
@@ -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
View 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
View 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
View File

@@ -0,0 +1 @@
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "luminic-space"

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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/**"),

View File

@@ -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

View File

@@ -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,

View File

@@ -1,27 +1,53 @@
package space.luminic.finance.dtos
data class UserDTO (
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>
) {
data class AuthUserDTO (
data class AuthUserDTO(
var username: String,
var password: String,
)
data class RegisterUserDTO (
data class RegisterUserDTO(
var username: String,
var firstName: String,
var password: String,
)
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,
)
}

View File

@@ -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,

View File

@@ -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
)
}

View 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,
}
}

View File

@@ -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,
) {

View File

@@ -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(),

View 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)
}

View 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")
}
}

View File

@@ -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())
}

View File

@@ -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>
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -4,18 +4,20 @@ 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
) : UserRepo{
) : UserRepo {
private fun userRowMapper() = RowMapper { rs, _ ->
User(
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,

View File

@@ -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("Пользователь уже зарегистрирован")
}

View File

@@ -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()
}

View File

@@ -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
): RecurrentOperationService {
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!!
)
}
}
}

View 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()
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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")
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}

View File

@@ -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(), )
}
}
}

View File

@@ -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
}

View File

@@ -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())
}
}
}

View File

@@ -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
}
}

View File

@@ -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?
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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!

View File

@@ -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

View 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;;

View File

@@ -0,0 +1,2 @@
ALTER TABLE finance.users
ADD CONSTRAINT uq_users_username UNIQUE (username);

View 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);

View 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);

View File

@@ -0,0 +1,3 @@
-- уникальное ограничение под ON CONFLICT (user_id, data_code)
ALTER TABLE finance.transactions
ALTER COLUMN category_id DROP NOT NULL;

View 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';

View File

@@ -0,0 +1,3 @@
alter table finance.transactions
add column tg_chat_id bigint null,
add column tg_message_id bigint null;

View File

@@ -0,0 +1,3 @@
alter table finance.category_jobs
add column tg_chat_id bigint null,
add column tg_message_id bigint null;

View 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);