+ google drive

This commit is contained in:
xds
2026-03-10 15:11:09 +03:00
parent cbcd21946c
commit 35852ae0c9
18 changed files with 198 additions and 37 deletions

View File

@@ -35,6 +35,14 @@ repositories {
dependencies {
// Excel
implementation("org.apache.poi:poi-ooxml:5.2.3")
// Google API
implementation("com.google.api-client:google-api-client:2.4.0")
implementation("com.google.apis:google-api-services-drive:v3-rev20240123-2.0.0")
implementation("com.google.auth:google-auth-library-oauth2-http:1.23.0")
// Spring
implementation("org.springframework.boot:spring-boot-starter-cache")
implementation("org.springframework.boot:spring-boot-starter-security")

View File

@@ -13,6 +13,8 @@ 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 space.luminic.finance.services.GoogleDriveService
import space.luminic.finance.services.UserService
import java.net.URLDecoder
import java.security.MessageDigest
import java.time.Instant
@@ -23,6 +25,8 @@ import javax.crypto.spec.SecretKeySpec
@RequestMapping("/auth")
class AuthController(
private val authService: AuthService,
private val googleDriveService: GoogleDriveService,
private val userService: UserService,
@Value("\${telegram.bot.token}") private val botToken: String
) {
@@ -183,4 +187,18 @@ class AuthController(
return authService.getSecurityUser().toDto()
}
@PostMapping("/me/google-drive")
fun linkGoogleDrive(@RequestBody request: UserDTO.GoogleAuthDTO): Map<String, String> {
val user = authService.getSecurityUser()
val refreshToken = googleDriveService.exchangeCodeForRefreshToken(request.code)
if (refreshToken.isNotEmpty()) {
user.googleRefreshToken = refreshToken
userService.update(user)
return mapOf("status" to "success", "message" to "Google Drive linked successfully")
} else {
throw IllegalArgumentException("Failed to exchange code for refresh token")
}
}
}

View File

@@ -1,5 +1,8 @@
package space.luminic.finance.api
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
import io.swagger.v3.oas.annotations.security.SecurityScheme
import org.springframework.web.bind.annotation.*
@@ -26,6 +29,18 @@ class TransactionController (
return transactionService.getTransactions(spaceId, filter).map { it.toDto() }
}
@PostMapping("/_export")
fun exportExcel(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter): ResponseEntity<ByteArray> {
val excelBytes = transactionService.generateExcel(spaceId, filter)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_OCTET_STREAM
headers.setContentDispositionFormData("attachment", "transactions.xlsx")
return ResponseEntity.ok()
.headers(headers)
.body(excelBytes)
}
@GetMapping("/{transactionId}")
fun getTransaction(@PathVariable spaceId: Int, @PathVariable transactionId: Int): TransactionDTO {
return transactionService.getTransaction(spaceId, transactionId).toDto()

View File

@@ -48,6 +48,9 @@ data class UserDTO(
)
data class GoogleAuthDTO(
val code: String
)
}

View File

@@ -20,6 +20,7 @@ data class User(
@CreatedDate val createdAt: Instant? = null,
@LastModifiedDate var updatedAt: Instant? = null,
var roles: List<String> = listOf(),
var googleRefreshToken: String? = null,
)

View File

@@ -6,6 +6,7 @@ import java.time.LocalDateTime
@Repository
interface SpaceRepo {
fun findAll(): List<Space>
fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space>
fun findSpacesAvailableForUser(userId: Int): List<Space>
fun findSpaceById(id: Int, userId: Int): Space?

View File

@@ -23,6 +23,7 @@ class SpaceRepoImpl(
username = rs.getString("username"),
firstName = rs.getString("first_name"),
password = rs.getString("password"),
googleRefreshToken = rs.getString("google_refresh_token")
),
participants = userRepo.findParticipantsBySpace(rs.getInt("id")).toSet(),
isDeleted = rs.getBoolean("is_deleted"),
@@ -85,6 +86,21 @@ class SpaceRepoImpl(
return spaceMap.map { it.value }
}
override fun findAll(): List<Space> {
val sql = """
select s.*,
u.id as owner_id,
u.username as username,
u.first_name as first_name,
u.password as password,
u.google_refresh_token as google_refresh_token
from finance.spaces s
join finance.users u on u.id = s.owner_id
where s.is_deleted = false
""".trimIndent()
return jdbcTemplate.query(sql, spaceRowMapper())
}
override fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space> {
val sql = """
select s.id as s_id,

View File

@@ -23,7 +23,8 @@ class UserRepoImpl(
regDate = rs.getDate("reg_date").toLocalDate(),
createdAt = rs.getTimestamp("created_at").toInstant(),
updatedAt = rs.getTimestamp("updated_at").toInstant(),
roles = (rs.getArray("roles")?.array as? Array<String>)?.toList() ?: emptyList()
roles = (rs.getArray("roles")?.array as? Array<String>)?.toList() ?: emptyList(),
googleRefreshToken = rs.getString("google_refresh_token")
)
}
@@ -58,7 +59,7 @@ class UserRepoImpl(
override fun create(user: User): User {
val sql =
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate) returning ID"
"insert into finance.users(username, first_name, tg_id, tg_user_name, photo_url, password, is_active, reg_date, google_refresh_token) values (:username, :firstname, :tg_id, :tg_user_name, :photo_url, :password, :isActive, :regDate, :googleRefreshToken) returning ID"
val params = mapOf(
"username" to user.username,
"firstname" to user.firstName,
@@ -68,6 +69,7 @@ class UserRepoImpl(
"password" to user.password,
"isActive" to user.isActive,
"regDate" to user.regDate,
"googleRefreshToken" to user.googleRefreshToken
)
val savedId = jdbcTemplate.queryForObject(sql, params, Int::class.java)
user.id = savedId
@@ -75,7 +77,33 @@ class UserRepoImpl(
}
override fun update(user: User): User {
TODO("Not yet implemented")
val sql = """
update finance.users
set username = :username,
first_name = :firstname,
tg_id = :tg_id,
tg_user_name = :tg_user_name,
photo_url = :photo_url,
password = :password,
is_active = :isActive,
reg_date = :regDate,
google_refresh_token = :googleRefreshToken
where id = :id
""".trimIndent()
val params = mapOf(
"id" to user.id,
"username" to user.username,
"firstname" to user.firstName,
"tg_id" to user.tgId,
"tg_user_name" to user.tgUserName,
"photo_url" to user.photoUrl,
"password" to user.password,
"isActive" to user.isActive,
"regDate" to user.regDate,
"googleRefreshToken" to user.googleRefreshToken
)
jdbcTemplate.update(sql, params)
return user
}
override fun deleteById(id: Long) {

View File

@@ -15,7 +15,7 @@ import space.luminic.finance.models.Transaction
import java.time.format.DateTimeFormatter
@Service
class NotificationServiceImpl(private val userService: UserService, private val bot: Bot,) : NotificationService {
class NotificationServiceImpl(private val userService: UserService, private val bot: Bot) : NotificationService {
private val logger = LoggerFactory.getLogger(this.javaClass)
@@ -42,7 +42,7 @@ class NotificationServiceImpl(private val userService: UserService, private val
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions")
WebAppInfo("https://app.luminic.space/transactions?mode=from_bot")
)
)
)
@@ -113,10 +113,11 @@ class NotificationServiceImpl(private val userService: UserService, private val
}"
)
}
var text = "${user.firstName} обновил транзакцию ${tx.comment}\n\n"
text += changes.joinToString("\n") { it }
space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) }
if (changes.isNotEmpty()) {
var text = "${user.firstName} обновил транзакцию ${tx.comment}\n\n"
text += changes.joinToString("\n") { it }
space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) }
}
} ?: logger.warn("No tx2 provided when update")
}

View File

@@ -37,6 +37,11 @@ interface TransactionService {
filter: TransactionsFilter
): List<Transaction>
fun generateExcel(
spaceId: Int,
filter: TransactionsFilter
): ByteArray
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?)

View File

@@ -11,8 +11,10 @@ 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
import java.io.ByteArrayOutputStream
import java.time.LocalDate
import java.time.LocalDateTime
import org.apache.poi.xssf.usermodel.XSSFWorkbook
@Service
class TransactionServiceImpl(
@@ -34,6 +36,45 @@ class TransactionServiceImpl(
return transactions
}
override fun generateExcel(
spaceId: Int,
filter: TransactionService.TransactionsFilter
): ByteArray {
val transactions = getTransactions(spaceId, filter)
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet("Transactions")
val headerRow = sheet.createRow(0)
headerRow.createCell(0).setCellValue("ID")
headerRow.createCell(1).setCellValue("Date")
headerRow.createCell(2).setCellValue("Type")
headerRow.createCell(3).setCellValue("Kind")
headerRow.createCell(4).setCellValue("Category")
headerRow.createCell(5).setCellValue("Amount")
headerRow.createCell(6).setCellValue("Comment")
var rowNum = 1
for (transaction in transactions) {
val row = sheet.createRow(rowNum++)
row.createCell(0).setCellValue(transaction.id?.toDouble() ?: 0.0)
row.createCell(1).setCellValue(transaction.date.toString())
row.createCell(2).setCellValue(transaction.type.displayName)
row.createCell(3).setCellValue(transaction.kind.displayName)
row.createCell(4).setCellValue(transaction.category?.name ?: "")
row.createCell(5).setCellValue(transaction.amount.toDouble())
row.createCell(6).setCellValue(transaction.comment)
}
for (i in 0..6) {
sheet.autoSizeColumn(i)
}
val outputStream = ByteArrayOutputStream()
workbook.write(outputStream)
workbook.close()
return outputStream.toByteArray()
}
override fun getTransaction(
spaceId: Int,
transactionId: Int
@@ -112,7 +153,7 @@ class TransactionServiceImpl(
val newKind = if (
!existingTransaction.isDone &&
transaction.isDone &&
(today.isAfter(transaction.date) || today.isEqual(transaction.date))
(today.isAfter(transaction.date) || today.isEqual(transaction.date) || today.isBefore(transaction.date))
) Transaction.TransactionKind.INSTANT else transaction.kind
val updatedTransaction = Transaction(
id = existingTransaction.id,

View File

@@ -40,4 +40,7 @@ class UserService(val userRepo: UserRepo) {
return userRepo.findAll()
}
fun update(user: User): User {
return userRepo.update(user)
}
}

View File

@@ -12,6 +12,7 @@ import com.github.kotlintelegrambot.types.TelegramBotResult
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
import space.luminic.finance.repos.CategoryRepo
import space.luminic.finance.repos.TransactionRepo
@@ -38,9 +39,18 @@ class CategorizeService(
runCatching {
val tx = transactionRepo.findBySpaceIdAndId(job.spaceId, job.txId)
?: throw IllegalArgumentException("Transaction ${job.txId} not found")
val categories = categoriesRepo.findBySpaceId(job.spaceId).filter {
it.type == when (tx.type) {
Transaction.TransactionType.INCOME -> Category.CategoryType.INCOME
Transaction.TransactionType.EXPENSE -> Category.CategoryType.EXPENSE
else -> it.type
}
}
val res = gpt.suggestCategory(
tx,
categoriesRepo.findBySpaceId(job.spaceId)
categories
) // тут твой вызов GPT
var message: TelegramBotResult<Message>? = null
@@ -60,7 +70,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot")
)
)
)
@@ -85,7 +95,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot"),
)
)
),
@@ -131,7 +141,7 @@ class CategorizeService(
listOf(
InlineKeyboardButton.WebApp(
"Открыть в WebApp",
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit")
WebAppInfo("https://app.luminic.space/transactions/${tx.id}/edit?mode=from_bot"),
)
)
),

View File

@@ -34,10 +34,10 @@ class DeepSeekCategorizationService(
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}\" }
{ \"type\": \"${tx.type.displayName}\", \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
""".trimIndent()
val prompt = """
Пользователь имеет следующие категории:
Пользователь имеет следующие категории (${tx.type.displayName}):
$catList
Задача:
@@ -88,7 +88,7 @@ class DeepSeekCategorizationService(
override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String {
mapper.registerModule(JavaTimeModule());
if (dashboardData.totalIncome == 0 && dashboardData.totalExpense == 0){
return "Начните записывать траты и поступления и мы начнем их анализировать!"
return """{ "common": "Начните записывать траты и поступления и мы начнем их анализировать!", "categoryAnalysis": "", "keyInsights": "", "recommendations": "" }"""
} else {
var prompt = """
You are a personal finance analyst.

View File

@@ -22,19 +22,19 @@ class QwenCategorizationService(
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 = """
{ \"type\": \"${tx.type.displayName}\", \"amount\": ${tx.amount}, \"comment\": \"${tx.comment}\", \"date\":${tx.date}\" }
""".trimIndent()
val prompt = """
Пользователь имеет следующие категории (${tx.type.displayName}):
$catList
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)".
Задача:
1. Определите наиболее подходящую категорию из списка выше для транзакции пользователя.
2. Верните ответ в формате: "ID категории имя категории (вероятность)", например "3 Продукты (0.87)".
...
3. Если ни одна категория из списка не подходит, верните: "0 Другое (вероятность)".
Ответ должен быть кратким, одной строкой, без дополнительных пояснений.

View File

@@ -110,7 +110,7 @@ class BotService(
listOf(
InlineKeyboardButton.WebApp(
text = "Открыть WebApp",
webApp = WebAppInfo(url = "https://app.luminic.space")
webApp = WebAppInfo(url = "https://app.luminic.space?mode=from_bot")
)
)
)
@@ -131,11 +131,18 @@ class BotService(
when (state?.state) {
State.StateCode.SPACE_SELECTED -> {
try {
val parts = message.text!!.trim().split(" ", limit = 2)
if (parts.isEmpty()) {
var text = message.text!!.trim()
var type = Transaction.TransactionType.EXPENSE
if (text.startsWith("+")) {
type = Transaction.TransactionType.INCOME
text = text.substring(1).trim()
}
val parts = text.split(" ", limit = 2)
if (parts.isEmpty() || parts[0].isEmpty()) {
bot.sendMessage(
chatId = ChatId.fromId(message.chat.id),
text = "Введите сумму и комментарий, например: `250 обед`",
text = "Введите сумму и комментарий, например: `250 обед` или `+1000 зп` ",
parseMode = ParseMode.MARKDOWN
)
@@ -161,7 +168,7 @@ class BotService(
?: throw IllegalArgumentException("selected space is empty"),
user.id!!,
TransactionDTO.CreateTransactionDTO(
Transaction.TransactionType.EXPENSE,
type,
Transaction.TransactionKind.INSTANT,
comment = comment,
amount = amount.toBigDecimal(),
@@ -203,7 +210,7 @@ class BotService(
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>",
text = "Успешно!\n\nМы готовы принимать Ваши транзакции.\n\nПросто пишите их в формате:\n\n <i>сумма комментарий</i> (расходы)\n\n <i>+сумма комментарий</i> (пополнения)\n\n <b>Первой обязательно должна быть сумма!</b>",
parseMode = ParseMode.HTML,
replyMarkup = buildMenu(callbackQuery.from.id)
)

View File

@@ -2,7 +2,8 @@ package space.luminic.finance.services.telegram
import space.luminic.finance.dtos.TransactionDTO
interface TransactionService {
interface
TransactionService {
fun createTransaction(spaceId: Int, userId: Int, transaction: TransactionDTO.CreateTransactionDTO, chatId: Long, messageId: Long ): Int
}

View File

@@ -35,4 +35,7 @@ spring.flyway.schemas=finance
spring.jpa.properties.hibernate.default_schema=finance
spring.jpa.properties.hibernate.default_batch_fetch_size=50
qwen.api_key=sk-991942d15b424cc89513498bb2946045
ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2
ds.api_key=sk-b5949728e79747f08af0a1d65bc6a7a2\ngoogle.client-id=\ngoogle.client-secret=\ngoogle.redirect-uri=
google.client-id=112729998586-q39qsptu67lqeej0356m01e1ghptuajk.apps.googleusercontent.com
google.client-secret=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
google.redirect-uri=https://app.luminic.space