recurrents

This commit is contained in:
xds
2025-11-17 15:02:47 +03:00
parent d0cae182b7
commit 12afd1f90e
48 changed files with 1479 additions and 120 deletions

View File

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

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

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