diff --git a/Dockerfile b/Dockerfile index db1aaad..4971da5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ 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 ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" diff --git a/build.gradle.kts b/build.gradle.kts index da62362..b295573 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 65704b8..1f0f350 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,6 @@ services: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/luminic-space-db SPRING_DATASOURCE_USERNAME: luminicspace SPRING_DATASOURCE_PASSWORD: LS1q2w3e4r! - MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info ports: - "8089:8089" restart: unless-stopped diff --git a/src/main/kotlin/space/luminic/finance/api/AuthController.kt b/src/main/kotlin/space/luminic/finance/api/AuthController.kt index f48d687..195ff5d 100644 --- a/src/main/kotlin/space/luminic/finance/api/AuthController.kt +++ b/src/main/kotlin/space/luminic/finance/api/AuthController.kt @@ -1,8 +1,8 @@ package space.luminic.finance.api -import org.apache.commons.codec.digest.DigestUtils.sha256 -import org.apache.commons.codec.digest.HmacUtils.hmacSha256 +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 @@ -13,6 +13,7 @@ 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 @@ -27,28 +28,83 @@ class AuthController( private val logger = LoggerFactory.getLogger(javaClass) - fun verifyTelegramAuth(data: Map, botToken: String): Boolean { - val hash = data["hash"] ?: return false + fun verifyTelegramAuth( + loginData: Map? = null, // from login widget + webAppInitData: String? = null + ): Boolean { - val dataCheckString = data - .filterKeys { it != "hash" } - .toSortedMap() - .map { "${it.key}=${it.value}" } - .joinToString("\n") + // --- 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 = sha256(botToken) - val hmacHex = hmacSha256(secretKey, dataCheckString) + val secretKey = MessageDigest.getInstance("SHA-256") + .digest(botToken.toByteArray()) - if (hmacHex != hash) return false + val hmac = Mac.getInstance("HmacSHA256").apply { + init(SecretKeySpec(secretKey, "HmacSHA256")) + }.doFinal(dataCheckString.toByteArray()) + .joinToString("") { "%02x".format(it) } - val authDate = data["auth_date"]?.toLongOrNull() ?: return false - val now = Instant.now().epochSecond + val authDate = loginData["auth_date"]?.toLongOrNull() ?: return false + if (Instant.now().epochSecond - authDate > 3600) return false - // Опционально — запрет старых ответов (например, старше 1 часа) - val maxAgeSeconds = 3600 - if (now - authDate > maxAgeSeconds) return false + if (hmac == hash) return true + } + } - return true + // --- WEBAPP CHECK --- + // --- WEBAPP CHECK --- + if (webAppInitData != null) { + // Разбираем query string корректно (учитывая '=' внутри значения) + val pairs: Map = 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 = @@ -62,6 +118,7 @@ class AuthController( return hashBytes.joinToString("") { "%02x".format(it) } } + @GetMapping("/test") fun test(): String { val authentication = SecurityContextHolder.getContext().authentication @@ -80,18 +137,45 @@ class AuthController( return authService.register(request.username, request.password, request.firstName).toDto() } + private val json = Json { ignoreUnknownKeys = true } + + @PostMapping("/tg-login") fun tgLogin(@RequestBody tgUser: UserDTO.TelegramAuthDTO): Map { // println(tgUser.hash) // println(botToken) - val ok = verifyTelegramAuth(tgUser.toTelegramMap(), botToken) - if (!ok) throw IllegalArgumentException("Invalid Telegram login") - return mapOf("token" to authService.tgAuth(tgUser)) + 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(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") + } } - @GetMapping("/me") fun getMe(): UserDTO { logger.info("Get Me") diff --git a/src/main/kotlin/space/luminic/finance/api/SpaceController.kt b/src/main/kotlin/space/luminic/finance/api/SpaceController.kt index 1dfc0e2..51c2e2f 100644 --- a/src/main/kotlin/space/luminic/finance/api/SpaceController.kt +++ b/src/main/kotlin/space/luminic/finance/api/SpaceController.kt @@ -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 diff --git a/src/main/kotlin/space/luminic/finance/dtos/TransactionDTO.kt b/src/main/kotlin/space/luminic/finance/dtos/TransactionDTO.kt index 9365e0b..4397192 100644 --- a/src/main/kotlin/space/luminic/finance/dtos/TransactionDTO.kt +++ b/src/main/kotlin/space/luminic/finance/dtos/TransactionDTO.kt @@ -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, diff --git a/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt b/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt index de75793..3e6d5f8 100644 --- a/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt +++ b/src/main/kotlin/space/luminic/finance/dtos/UserDTO.kt @@ -1,8 +1,8 @@ package space.luminic.finance.dtos -import java.util.Date +import kotlinx.serialization.Serializable -data class UserDTO ( +data class UserDTO( var id: Int, val username: String, var firstName: String, @@ -13,12 +13,12 @@ data class UserDTO ( ) { - 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, @@ -26,15 +26,27 @@ data class UserDTO ( ) data class TelegramAuthDTO( - val id: Long, + 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 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, + + + ) + } diff --git a/src/main/kotlin/space/luminic/finance/mappers/TransactionMapper.kt b/src/main/kotlin/space/luminic/finance/mappers/TransactionMapper.kt index 637c728..5d056ea 100644 --- a/src/main/kotlin/space/luminic/finance/mappers/TransactionMapper.kt +++ b/src/main/kotlin/space/luminic/finance/mappers/TransactionMapper.kt @@ -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, diff --git a/src/main/kotlin/space/luminic/finance/models/State.kt b/src/main/kotlin/space/luminic/finance/models/State.kt new file mode 100644 index 0000000..ac12772 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/models/State.kt @@ -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 = mapOf() +) { + enum class StateCode { + AWAIT_SPACE_SELECT, + SPACE_SELECTED, + AWAIT_TRANSACTION, + } + +} + + diff --git a/src/main/kotlin/space/luminic/finance/models/Transaction.kt b/src/main/kotlin/space/luminic/finance/models/Transaction.kt index b707089..152d97a 100644 --- a/src/main/kotlin/space/luminic/finance/models/Transaction.kt +++ b/src/main/kotlin/space/luminic/finance/models/Transaction.kt @@ -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, ) { diff --git a/src/main/kotlin/space/luminic/finance/repos/BotRepo.kt b/src/main/kotlin/space/luminic/finance/repos/BotRepo.kt new file mode 100644 index 0000000..449e139 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/repos/BotRepo.kt @@ -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) + fun clearState(userId: Int) +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt new file mode 100644 index 0000000..811d224 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/repos/BotRepoImpl.kt @@ -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() + + 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 + ) { + // 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") + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/CategoryRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/CategoryRepoImpl.kt index 59041a7..f21342e 100644 --- a/src/main/kotlin/space/luminic/finance/repos/CategoryRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/CategoryRepoImpl.kt @@ -24,7 +24,7 @@ class CategoryRepoImpl( } override fun findBySpaceId(spaceId: Int): List { - 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()) } diff --git a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepo.kt b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepo.kt index bc7d493..7a27845 100644 --- a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepo.kt +++ b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepo.kt @@ -5,7 +5,9 @@ import space.luminic.finance.models.RecurrentOperation interface RecurrentOperationRepo { fun findAllBySpaceId(spaceId: Int): List fun findBySpaceIdAndId(spaceId: Int, id: Int): RecurrentOperation? + fun findByDate( date: Int): List fun create(operation: RecurrentOperation, createdById: Int): Int fun update(operation: RecurrentOperation, updatedById: Int) fun delete(id: Int) + fun findRecurrentsToCreate(spaceId: Int): List } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt index d14a989..b0bd8a6 100644 --- a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt @@ -115,6 +115,40 @@ class RecurrentOperationRepoImpl( return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull() } + override fun findByDate( + date: Int + ): List { + 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 { + val sql = """ + select * from finance.transactions where space_id = :spaceId and t.date > + """.trimIndent() + TODO("Not ready") + } + + } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/TokenRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/TokenRepoImpl.kt index 831ffae..57135e3 100644 --- a/src/main/kotlin/space/luminic/finance/repos/TokenRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/TokenRepoImpl.kt @@ -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 diff --git a/src/main/kotlin/space/luminic/finance/repos/TransactionRepo.kt b/src/main/kotlin/space/luminic/finance/repos/TransactionRepo.kt index 84ac511..de2b7c9 100644 --- a/src/main/kotlin/space/luminic/finance/repos/TransactionRepo.kt +++ b/src/main/kotlin/space/luminic/finance/repos/TransactionRepo.kt @@ -6,7 +6,11 @@ interface TransactionRepo { fun findAllBySpaceId(spaceId: Int): List fun findBySpaceIdAndId(spaceId: Int, id: Int): Transaction? fun create(transaction: Transaction, userId: Int): Int + fun createBatch(transactions: List, userId: Int) fun update(transaction: Transaction): Int fun delete(transactionId: Int) + fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) + + fun setCategory(txId:Int, categoryId: Int) } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt index ff41ce9..867e26c 100644 --- a/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt @@ -13,21 +13,26 @@ class TransactionRepoImpl( ) : TransactionRepo { private fun transactionRowMapper() = RowMapper { rs, _ -> + 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")), + description = rs.getString(("c_description")), + 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(), + ) + 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 = findBySpaceIdAndId(spaceId = rs.getInt("t_space_id"), rs.getInt("t_parent_id")), + parent = parent, type = Transaction.TransactionType.valueOf(rs.getString("t_type")), kind = Transaction.TransactionKind.valueOf(rs.getString("t_kind")), - category = Category( - id = rs.getInt("c_id"), - type = Category.CategoryType.valueOf(rs.getString("c_type")), - name = rs.getString(("c_name")), - description = rs.getString(("c_description")), - 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(), - ), + 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, 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) + } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/AuthService.kt b/src/main/kotlin/space/luminic/finance/services/AuthService.kt index 5cdcfa5..89af4f9 100644 --- a/src/main/kotlin/space/luminic/finance/services/AuthService.kt +++ b/src/main/kotlin/space/luminic/finance/services/AuthService.kt @@ -65,7 +65,7 @@ class AuthService( fun tgAuth(tgUser: UserDTO.TelegramAuthDTO): String { val user: User = try { - tgLogin(tgUser.id) + tgLogin(tgUser.id!!) } catch (e: NotFoundException) { registerTg(tgUser) } diff --git a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationService.kt b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationService.kt index b73c2bd..8002b0d 100644 --- a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationService.kt +++ b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationService.kt @@ -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() } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt index 22b752d..b3e6e1c 100644 --- a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt @@ -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 { 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() + 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!! + ) + } + } + + } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/Scheduler.kt b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt new file mode 100644 index 0000000..e776005 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/SpaceService.kt b/src/main/kotlin/space/luminic/finance/services/SpaceService.kt index a37dcfe..823beac 100644 --- a/src/main/kotlin/space/luminic/finance/services/SpaceService.kt +++ b/src/main/kotlin/space/luminic/finance/services/SpaceService.kt @@ -7,7 +7,7 @@ interface SpaceService { fun checkSpace(spaceId: Int): Space fun getSpaces(): List - 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) diff --git a/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt index 8525548..dcf8fa2 100644 --- a/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt @@ -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, diff --git a/src/main/kotlin/space/luminic/finance/services/TransactionService.kt b/src/main/kotlin/space/luminic/finance/services/TransactionService.kt index c166f91..20e09c8 100644 --- a/src/main/kotlin/space/luminic/finance/services/TransactionService.kt +++ b/src/main/kotlin/space/luminic/finance/services/TransactionService.kt @@ -11,9 +11,17 @@ interface TransactionService { val dateTo: LocalDate? = null, ) - fun getTransactions(spaceId: Int, filter: TransactionsFilter, sortBy: String, sortDirection: String): List - fun getTransaction(spaceId: Int, transactionId: Int): Transaction - fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int - fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int + fun getTransactions( + spaceId: Int, + filter: TransactionsFilter, + sortBy: String, + sortDirection: String + ): List + + fun getTransaction(spaceId: Int, transactionId: Int): Transaction + fun createTransaction(spaceId: Int, transaction: TransactionDTO.CreateTransactionDTO): Int + fun batchCreate(spaceId: Int, transactions: List, createdById: Int?) + fun updateTransaction(spaceId: Int, transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO): Int fun deleteTransaction(spaceId: Int, transactionId: Int) + fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt index 66596e9..2b578e7 100644 --- a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt @@ -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 { - 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, createdById: Int?) { + val userId = createdById ?: authService.getSecurityUserId() + val space = spaceService.getSpace(spaceId, userId) + val transactionsToCreate = mutableListOf() + 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") + } + } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeBotScheduler.kt b/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeBotScheduler.kt new file mode 100644 index 0000000..42a7cdf --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeBotScheduler.kt @@ -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() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeService.kt b/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeService.kt new file mode 100644 index 0000000..676a4dc --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/CategorizeService.kt @@ -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) { + 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? = 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 = "Определили категорию: ${category.name}.\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 = "Выбрана: ${tx.category!!.name}.\n\nЕсли это не так, измените это в WebApp.", + replyMarkup = InlineKeyboardMarkup.create( + listOf( + InlineKeyboardButton.WebApp( + "Открыть в WebApp", + WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit") + ) + ) + ), + parseMode = ParseMode.HTML + ) + } + + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobRepo.kt b/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobRepo.kt new file mode 100644 index 0000000..50da934 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobRepo.kt @@ -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 { + 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() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobSeeder.kt b/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobSeeder.kt new file mode 100644 index 0000000..6ad5e09 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/CategoryJobSeeder.kt @@ -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)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt b/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt new file mode 100644 index 0000000..cd98673 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt @@ -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): 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(), ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt b/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt new file mode 100644 index 0000000..65df916 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt @@ -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): CategorySuggestion +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt b/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt new file mode 100644 index 0000000..a781a39 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt @@ -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): 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()) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt new file mode 100644 index 0000000..41e8070 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt @@ -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>() + val row = mutableListOf() + 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>() + + // Кнопка с названием выбранного 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 сумма комментарий\n\n Первой обязательно должна быть сумма!", + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt new file mode 100644 index 0000000..8487813 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt @@ -0,0 +1,8 @@ +package space.luminic.finance.services.telegram + +import space.luminic.finance.models.Space + +interface SpaceService { + fun getSpaces(userId: Int): List + fun getSpace(spaceId: Int, userId: Int): Space? +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt new file mode 100644 index 0000000..f1d7c4c --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt @@ -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 { + 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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/TransactionService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionService.kt new file mode 100644 index 0000000..8693318 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionService.kt @@ -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 +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt new file mode 100644 index 0000000..c726e5f --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt @@ -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) + } + + + +} \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index c7e91b5..01185ba 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -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=7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks +# vector test +telegram.bot.token=8127199836:AAEPepyKDAf8PvFpw-fpxBXUuPdx_LS20fI nlp.address=http://127.0.0.1:8000 diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 36df05f..cdedff4 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,5 +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 @@ -9,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 = 7999296388:AAGXPE5r0yt3ZFehBoUh8FGm5FBbs9pYIks nlp.address=https://nlp.luminic.space -spring.datasource.url=jdbc:postgresql://postgresql:5432/luminic-space-db +#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! \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d9aff8b..542a0e6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,7 +4,7 @@ server.port=8089 server.servlet.context-path=/api #spring.webflux.base-path=/api -spring.profiles.active=dev +spring.profiles.active=prod spring.main.web-application-type=servlet server.compression.enabled=true @@ -17,17 +17,22 @@ 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 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 \ No newline at end of file +spring.jpa.properties.hibernate.default_batch_fetch_size=50 +qwen.api_key=sk-991942d15b424cc89513498bb2946045 +ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2 \ No newline at end of file diff --git a/src/main/resources/db/migration/V24__.sql b/src/main/resources/db/migration/V24__.sql new file mode 100644 index 0000000..f241ae2 --- /dev/null +++ b/src/main/resources/db/migration/V24__.sql @@ -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); + diff --git a/src/main/resources/db/migration/V25__.sql b/src/main/resources/db/migration/V25__.sql new file mode 100644 index 0000000..3dae4ed --- /dev/null +++ b/src/main/resources/db/migration/V25__.sql @@ -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); \ No newline at end of file diff --git a/src/main/resources/db/migration/V26__.sql b/src/main/resources/db/migration/V26__.sql new file mode 100644 index 0000000..86c57d1 --- /dev/null +++ b/src/main/resources/db/migration/V26__.sql @@ -0,0 +1,3 @@ +-- уникальное ограничение под ON CONFLICT (user_id, data_code) +ALTER TABLE finance.transactions + ALTER COLUMN category_id DROP NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V27__.sql b/src/main/resources/db/migration/V27__.sql new file mode 100644 index 0000000..cc944fc --- /dev/null +++ b/src/main/resources/db/migration/V27__.sql @@ -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'; \ No newline at end of file diff --git a/src/main/resources/db/migration/V28__.sql b/src/main/resources/db/migration/V28__.sql new file mode 100644 index 0000000..fdb741a --- /dev/null +++ b/src/main/resources/db/migration/V28__.sql @@ -0,0 +1,3 @@ +alter table finance.transactions +add column tg_chat_id bigint null, +add column tg_message_id bigint null; \ No newline at end of file diff --git a/src/main/resources/db/migration/V29__.sql b/src/main/resources/db/migration/V29__.sql new file mode 100644 index 0000000..a33ff6a --- /dev/null +++ b/src/main/resources/db/migration/V29__.sql @@ -0,0 +1,3 @@ +alter table finance.category_jobs + add column tg_chat_id bigint null, + add column tg_message_id bigint null; \ No newline at end of file diff --git a/src/main/resources/db/migration/V30__.sql b/src/main/resources/db/migration/V30__.sql new file mode 100644 index 0000000..92ce5cb --- /dev/null +++ b/src/main/resources/db/migration/V30__.sql @@ -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); \ No newline at end of file