feat: 支持数据库运行时配置与多数据库,重构设置页并完善打包

- 数据库支持 SQLite / MySQL 运行时配置与切换,未配置时仍可启动,配置后自动建表
- 新增默认分类与标签初始化,注册用户自动创建
- 新增注册开关(默认关闭,可在系统设置开启;无用户时允许创建首个账号)
- 合并数据库、注册与 AI 配置为「系统设置」页,拆分「模型使用情况」独立页面
- AI 一键设置支持新建分类与标签
- 前端构建产物打包进可执行 jar,后端增加 SPA 路由回退
- jar 更名为 finance-app,补全 Gradle Wrapper,移除 archive 任务
- 更新 DeepSeek 模型说明文案
This commit is contained in:
2026-09-18 12:33:41 +08:00
parent 16e4c30456
commit c1f5d02564
63 changed files with 3783 additions and 573 deletions
+28 -15
View File
@@ -6,7 +6,7 @@
| 层级 | 技术 |
|---|---|
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus + MySQL |
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus + MySQL / SQLite |
| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts |
| 构建 | Gradle 8.13 (后端) / npm (前端) |
| 认证 | JWT |
@@ -19,20 +19,23 @@
- JDK 21.0.12.1 Temurin LTS,安装在 `D:\venv\jdk-21`
- Node.js v24.0.0
## 数据库初始化
## 数据库配置
首次运行前需建表,执行 `src/main/resources/sql/init.sql`,或运行 Python 脚本:
系统支持在服务启动后再配置数据库,无需启动前准备:
```bash
pip install pymysql bcrypt
python init_db.py
python generate_test_data.py
```
- 未检测到配置时,应用仍可正常启动,前端会引导进入「初始化数据库」页面;
- 支持 **SQLite**(本地单文件,零配置)与 **MySQL** 两种数据库;
- 保存配置后立即生效并自动建表,无需手动执行 SQL;
- 配置持久化在 `config/database.json`(已加入 `.gitignore`),SQLite 数据默认位于 `data/` 目录;
- 新注册用户会自动获得一套默认分类与标签;
- 兼容旧部署:若无本地配置但存在 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD` 环境变量,会自动作为首次配置并保存。
登录后可通过右上角用户菜单 →「数据库设置」随时切换数据库;切换后原数据库的数据保留,不做自动迁移。
## 启动后端
```bash
cd my-app
cd finance-app
set JAVA_HOME=D:\venv\jdk-21
gradlew.bat bootRun
```
@@ -43,26 +46,36 @@ gradlew.bat bootRun
## 启动前端
```bash
cd my-app/frontend
cd finance-app/frontend
npm run dev
```
- 前端地址:http://localhost:5173
## 打包
## 构建可执行 jar(前后端一体化)
将项目源代码打包为 tar.gz(不含构建产物和 node_modules):
将前端构建产物一并打入 jar,单文件同时提供前端页面与后端接口:
```bash
gradlew.bat archive
gradle bootJar
```
输出文件:`build/distributions/finance-app-0.0.1-SNAPSHOT-{时间戳}.tar.gz`
- 该任务会先执行前端 `npm run build`,再把 `frontend/dist` 复制到 jar 内的 `BOOT-INF/classes/static/`
- 构建环境需具备 Node(开发容器通过 `NODE_HOME` 指向系统 nvm Node,npm 走本地 verdaccio 源)
- 输出文件:`build/libs/finance-app-0.0.1-SNAPSHOT.jar`
启动(默认 8080,可另指定端口):
```bash
java -jar build/libs/finance-app-0.0.1-SNAPSHOT.jar --server.port=18081
```
访问对应端口即可同时使用前端与接口;刷新 `/dashboard` 等子路由会由后端回退到 `index.html`。
## 项目结构
```
my-app/
finance-app/
├── build.gradle
├── src/main/java/cn/rosercode/finance/
│ ├── controller/ # 接口层
+39 -17
View File
@@ -31,6 +31,8 @@ dependencies {
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
implementation 'com.baomidou:mybatis-plus-jsqlparser:3.5.12'
runtimeOnly 'com.mysql:mysql-connector-j'
// SQLite 驱动,用于本地文件数据库
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.3'
implementation 'com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0'
@@ -54,24 +56,44 @@ tasks.named('test') {
useJUnitPlatform()
}
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
// ===== 前端构建与打包 =====
def frontendDir = file("${projectDir}/frontend")
def frontendDistDir = file("${frontendDir}/dist")
task archive(type: Tar) {
def timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
archiveFileName = "finance-app-${version}-${timestamp}.tar.gz"
destinationDirectory = file("${projectDir}/distributions")
compression = Compression.GZIP
// 解析 npm 可执行文件:优先使用 NODE_HOME,其次使用 PATH 中的 npm
def resolveNpm = {
def nodeHome = System.getenv('NODE_HOME')
return nodeHome ? "${nodeHome}/bin/npm" : 'npm'
}
from(projectDir) {
exclude 'build/**'
exclude '.gradle/**'
exclude 'distributions/**'
exclude 'frontend/node_modules/**'
exclude 'frontend/node_modules*/**'
exclude 'frontend/node_modules*.zip'
exclude 'frontend/dist/**'
exclude '**/*.log'
exclude 'logs/**'
// 安装前端依赖(node_modules 存在且 package.json 未变化时自动跳过)
tasks.register('npmInstall', Exec) {
group = 'frontend'
description = '安装前端依赖'
workingDir frontendDir
commandLine resolveNpm(), 'install'
inputs.file("${frontendDir}/package.json")
outputs.dir("${frontendDir}/node_modules")
}
// 构建前端产物
tasks.register('frontendBuild', Exec) {
group = 'frontend'
description = '构建前端产物(npm run build)'
dependsOn 'npmInstall'
workingDir frontendDir
commandLine resolveNpm(), 'run', 'build'
inputs.dir("${frontendDir}/src")
inputs.file("${frontendDir}/package.json")
inputs.file("${frontendDir}/index.html")
inputs.file("${frontendDir}/vite.config.ts")
outputs.dir(frontendDistDir)
}
// 将前端产物打入可执行 jar 的静态资源目录,实现前后端一体化部署
tasks.named('bootJar') {
dependsOn 'frontendBuild'
from(frontendDistDir) {
into 'BOOT-INF/classes/static'
}
}
+18 -2
View File
@@ -10,6 +10,12 @@ export interface AiConfig {
model: string
}
/** AI 建议新建的标签 */
export interface AiNewTag {
name: string
color: string
}
/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
export interface AiSuggestion {
transactionId: number
@@ -17,8 +23,11 @@ export interface AiSuggestion {
count: number
categoryId: number | null
categoryName: string | null
newCategoryName: string | null
newCategoryIcon: string | null
tagIds: number[]
tagNames: string[]
newTags: AiNewTag[]
reason: string
}
@@ -83,8 +92,15 @@ export function suggestColor(name: string) {
return request.post('/ai/color', { name }, { timeout: 60000 })
}
/** 一键应用建议:覆盖分类并追加标签 */
export function applyAiSuggestions(suggestions: { transactionId: number; categoryId: number | null; tagIds: number[] }[]) {
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
export function applyAiSuggestions(suggestions: {
transactionId: number
categoryId: number | null
newCategoryName?: string | null
newCategoryIcon?: string | null
tagIds: number[]
newTags?: AiNewTag[]
}[]) {
return request.post('/ai/apply', { suggestions })
}
+45
View File
@@ -0,0 +1,45 @@
// 数据库配置接口封装:状态查询、连接测试与配置保存
import request from './request'
/** 数据库类型 */
export type DatabaseType = 'MYSQL' | 'SQLITE'
/** 数据库配置请求参数 */
export interface DatabaseConfigPayload {
type: DatabaseType
sqlitePath?: string
host?: string
port?: number
database?: string
username?: string
password?: string
}
/** 数据库状态 */
export interface DatabaseStatus {
configured: boolean
type?: DatabaseType
typeName?: string
summary?: string
configFile?: string
host?: string
port?: number
database?: string
username?: string
sqlitePath?: string
}
/** 查询当前数据库配置状态 */
export function getDatabaseStatus() {
return request.get('/database/status')
}
/** 测试数据库连接是否可用 */
export function testDatabaseConnection(data: DatabaseConfigPayload) {
return request.post('/database/test', data)
}
/** 保存数据库配置并切换到新数据源 */
export function saveDatabaseConfig(data: DatabaseConfigPayload) {
return request.post('/database/config', data)
}
+10
View File
@@ -24,6 +24,16 @@ request.interceptors.response.use(
const data = response.data
// 如果业务状态码不是 200,视为请求失败
if (data.code !== 200) {
// 5031:数据库未配置,更新本地状态并跳转到配置引导页
if (data.code === 5031) {
import('../store/database').then(({ useDatabaseStore }) => {
const databaseStore = useDatabaseStore()
databaseStore.setStatus(null)
if (router.currentRoute.value.path !== '/database-setup') {
router.push('/database-setup')
}
})
}
return Promise.reject(new Error(data.message || '请求失败'))
}
return data
+17
View File
@@ -15,3 +15,20 @@ export function register(data: { username: string; password: string; nickname?:
export function getUserInfo() {
return request.get('/user/info')
}
/** 注册状态 */
export interface RegistrationStatus {
enabled: boolean
firstUser: boolean
allowed: boolean
}
/** 查询注册状态(无需登录) */
export function getRegistrationStatus() {
return request.get('/user/registration-status')
}
/** 更新注册开关(需登录) */
export function updateRegistration(enabled: boolean) {
return request.put('/user/registration', { enabled })
}
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
// AI 模型配置面板:配置 DeepSeek 的启用开关、API Key、基础地址与模型,并提供使用情况入口。
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText, NTag, NIcon,
} from 'naive-ui'
import {
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline,
} from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
const router = useRouter()
const message = useMessage()
const { t } = useI18n()
const saving = ref(false)
const testing = ref(false)
const hasApiKey = ref(false)
// 表单数据
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' })
/** 加载当前用户的 AI 配置 */
async function loadConfig() {
try {
const res: any = await getAiConfig()
const data = res.data
form.value = {
enabled: !!data.enabled,
apiKey: '',
baseUrl: data.baseUrl || 'https://api.deepseek.com',
model: data.model || 'deepseek-chat',
}
hasApiKey.value = !!data.hasApiKey
} catch (e: any) {
message.error(e.message || t('ai.loadFailed'))
}
}
/** 构建提交数据,apiKey 为空时不覆盖已保存的 Key */
function buildPayload() {
return {
enabled: form.value.enabled,
apiKey: form.value.apiKey || undefined,
baseUrl: form.value.baseUrl,
model: form.value.model,
}
}
/** 保存配置 */
async function handleSave() {
saving.value = true
try {
await saveAiConfig(buildPayload())
message.success(t('ai.saveSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
saving.value = false
}
}
/** 测试连接:先保存当前表单,再向 DeepSeek 发起测试请求 */
async function handleTest() {
testing.value = true
try {
await saveAiConfig(buildPayload())
await testAiConfig()
message.success(t('ai.testSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('ai.testFailed'))
} finally {
testing.value = false
}
}
/** 跳转到模型使用情况页面 */
function goUsage() {
router.push('/ai-usage')
}
onMounted(loadConfig)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SettingsOutline" />
<span>{{ t('settings.aiSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NSpace align="center" :size="8">
<NTag :type="form.enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ form.enabled ? t('ai.enabledOn') : t('ai.enabledOff') }}
</NTag>
<NButton size="small" quaternary @click="goUsage">
<template #icon>
<NIcon :component="StatsChartOutline" />
</template>
{{ t('settings.viewUsage') }}
</NButton>
</NSpace>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ t('ai.intro') }}
</NAlert>
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('ai.enabled')">
<div class="switch-row">
<NSwitch v-model:value="form.enabled" />
<NText depth="3" style="font-size: 12px;">{{ t('ai.enabledHint') }}</NText>
</div>
</NFormItem>
<NFormItem :label="t('ai.apiKey')">
<NInput
v-model:value="form.apiKey"
type="password"
show-password-on="click"
:placeholder="hasApiKey ? t('ai.apiKeyKeep') : t('ai.apiKeyPlaceholder')"
>
<template #prefix>
<NIcon :component="KeyOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.baseUrl')">
<NInput v-model:value="form.baseUrl" placeholder="https://api.deepseek.com">
<template #prefix>
<NIcon :component="LinkOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.model')">
<NInput v-model:value="form.model" placeholder="deepseek-chat">
<template #prefix>
<NIcon :component="CubeOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem>
<NSpace>
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('ai.save') }}
</NButton>
<NButton :loading="testing" :disabled="!form.enabled" @click="handleTest">
<template #icon>
<NIcon :component="FlashOutline" />
</template>
{{ t('ai.test') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NCard>
</template>
<style scoped>
.switch-row {
display: flex;
align-items: center;
gap: 12px;
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<script setup lang="ts">
// AI 使用情况面板:展示累计调用汇总、Token 明细与最近调用记录。
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NButton, NIcon, NStatistic, NDataTable, NTag,
NGrid, NGridItem, NEmpty,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
PulseOutline, CheckmarkCircleOutline, CloseCircleOutline, StatsChartOutline,
CloudUploadOutline, CloudDownloadOutline, FlashOutline, TimeOutline,
} from '@vicons/ionicons5'
import { getAiUsage } from '../api/ai'
import type { AiUsageResult } from '../api/ai'
const message = useMessage()
const { t } = useI18n()
const usage = ref<AiUsageResult | null>(null) // AI 使用情况
const usageLoading = ref(false) // 使用情况加载状态
/** 加载 AI 使用情况(累计汇总 + 最近明细) */
async function loadUsage() {
usageLoading.value = true
try {
const res: any = await getAiUsage()
usage.value = res.data
} catch (e: any) {
message.error(e.message || t('ai.usage.loadFailed'))
} finally {
usageLoading.value = false
}
}
/** 数字千分位格式化 */
function formatNumber(value: number | null | undefined) {
return value == null ? '0' : Number(value).toLocaleString()
}
/** 使用概览卡片配置 */
const overviewStats = computed(() => [
{ key: 'calls', label: t('ai.usage.totalCalls'), value: usage.value?.summary.totalCalls ?? 0, icon: PulseOutline, color: '#2080f0' },
{ key: 'success', label: t('ai.usage.successCalls'), value: usage.value?.summary.successCalls ?? 0, icon: CheckmarkCircleOutline, color: '#18a058' },
{ key: 'failed', label: t('ai.usage.failedCalls'), value: usage.value?.summary.failedCalls ?? 0, icon: CloseCircleOutline, color: '#d03050' },
{ key: 'total', label: t('ai.usage.totalTokens'), value: usage.value?.summary.totalTokens ?? 0, icon: StatsChartOutline, color: '#7c3aed' },
])
/** Token 明细卡片配置 */
const tokenStats = computed(() => [
{ key: 'prompt', label: t('ai.usage.promptTokens'), value: usage.value?.summary.promptTokens ?? 0, icon: CloudUploadOutline, color: '#2080f0' },
{ key: 'completion', label: t('ai.usage.completionTokens'), value: usage.value?.summary.completionTokens ?? 0, icon: CloudDownloadOutline, color: '#f0a020' },
{ key: 'cached', label: t('ai.usage.cachedTokens'), value: usage.value?.summary.cachedTokens ?? 0, icon: FlashOutline, color: '#18a058' },
])
/** 使用明细表格列定义(随语言切换响应式更新) */
const usageColumns = computed<DataTableColumns<any>>(() => [
{
title: t('ai.usage.column.time'), key: 'createdAt', width: 160,
render: (row) => {
if (!row.createdAt) return '-'
const [date, time] = String(row.createdAt).replace('T', ' ').split(' ')
return h('div', { class: 'cell-time' }, [
h('div', null, date),
h('div', { class: 'cell-time-sub' }, (time || '').substring(0, 8)),
])
},
},
{
title: t('ai.usage.column.type'), key: 'type', width: 120,
render: (row) => h(NTag, { type: row.type === 'SUGGEST' ? 'info' : 'default', size: 'small', bordered: false },
{ default: () => t(`ai.usage.type.${String(row.type).toLowerCase()}`) }),
},
{
title: t('ai.usage.column.count'), key: 'transactionCount', width: 100, align: 'right', titleAlign: 'right',
render: (row) => formatNumber(row.transactionCount),
},
{
title: t('ai.usage.column.promptTokens'), key: 'promptTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.promptTokens)),
},
{
title: t('ai.usage.column.completionTokens'), key: 'completionTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.completionTokens)),
},
{
title: t('ai.usage.column.totalTokens'), key: 'totalTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num cell-total' }, formatNumber(row.totalTokens)),
},
{
title: t('ai.usage.column.duration'), key: 'durationMs', width: 100, align: 'right', titleAlign: 'right',
render: (row) => (row.durationMs != null ? `${formatNumber(row.durationMs)} ms` : '-'),
},
{
title: t('ai.usage.column.status'), key: 'success', width: 110, align: 'center', titleAlign: 'center',
render: (row) => (row.success
? h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => t('ai.usage.success') })
: h('span', { title: row.errorMessage || '' },
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => t('ai.usage.failed') }))),
},
])
onMounted(loadUsage)
</script>
<template>
<NCard :bordered="false" class="usage-card">
<template #header>
<NSpace justify="space-between" align="center" style="width: 100%;">
<NSpace align="center" :size="8">
<NIcon :component="StatsChartOutline" />
<span>{{ t('ai.usage.title') }}</span>
</NSpace>
<NButton size="small" :loading="usageLoading" @click="loadUsage">
<template #icon>
<NIcon :component="TimeOutline" />
</template>
{{ t('ai.usage.refresh') }}
</NButton>
</NSpace>
</template>
<!-- 调用概览 -->
<NGrid cols="2 m:4" :x-gap="12" :y-gap="12">
<NGridItem v-for="item in overviewStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- Token 明细 -->
<NGrid cols="1 s:3" :x-gap="12" :y-gap="12" style="margin-top: 12px;">
<NGridItem v-for="item in tokenStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- 最近调用明细 -->
<div class="table-title">{{ t('ai.usage.recentTitle') }}</div>
<NDataTable
:columns="usageColumns"
:data="usage?.recent ?? []"
:loading="usageLoading"
size="small"
:bordered="false"
:striped="true"
class="usage-table"
>
<template #empty>
<NEmpty :description="t('ai.usage.empty')" />
</template>
</NDataTable>
</NCard>
</template>
<style scoped>
.table-title {
margin: 22px 0 10px;
font-size: 13px;
font-weight: 600;
color: #666;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.02);
transition: background 0.2s ease;
}
.stat-card:hover {
background: rgba(0, 0, 0, 0.04);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
flex-shrink: 0;
}
.cell-time {
line-height: 1.35;
}
.cell-time-sub {
color: #999;
font-size: 12px;
}
.cell-num {
font-variant-numeric: tabular-nums;
}
.cell-total {
font-weight: 600;
}
.usage-table :deep(.n-data-table-th) {
background-color: rgba(0, 0, 0, 0.025);
font-weight: 600;
font-size: 12px;
}
.usage-table :deep(.n-data-table-td) {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
// 数据库配置面板:可复用于「系统设置」页与首次配置引导页。
// setup 为 true 时表示首次引导,保存成功后跳转登录页。
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NInputNumber, NButton, NSpace,
NAlert, NTag, NRadioGroup, NRadioButton, NIcon, NText, NDescriptions, NDescriptionsItem,
} from 'naive-ui'
import { ServerOutline, SaveOutline, FlashOutline, FolderOpenOutline } from '@vicons/ionicons5'
import { getDatabaseStatus, testDatabaseConnection, saveDatabaseConfig } from '../api/database'
import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
import { useDatabaseStore } from '../store/database'
const props = defineProps<{ setup?: boolean }>()
const router = useRouter()
const message = useMessage()
const { t } = useI18n()
const databaseStore = useDatabaseStore()
const testing = ref(false)
const saving = ref(false)
const configured = ref(false)
const hasPassword = ref(false)
// 表单数据
const form = ref({
type: 'SQLITE' as DatabaseType,
sqlitePath: 'data/finance.db',
host: 'localhost',
port: 3306,
database: '',
username: '',
password: '',
})
/** 加载当前数据库状态并回显到表单 */
async function loadStatus() {
try {
const res: any = await getDatabaseStatus()
const data = res.data
configured.value = !!data?.configured
if (data?.configured) {
form.value.type = data.type
if (data.type === 'SQLITE') {
form.value.sqlitePath = data.sqlitePath || 'data/finance.db'
} else {
form.value.host = data.host || 'localhost'
form.value.port = data.port || 3306
form.value.database = data.database || ''
form.value.username = data.username || ''
}
hasPassword.value = data.type === 'MYSQL'
}
form.value.password = ''
} catch (e: any) {
message.error(e.message || t('database.loadFailed'))
}
}
/** 构建提交给后端的配置对象 */
function buildPayload(): DatabaseConfigPayload {
if (form.value.type === 'SQLITE') {
return { type: 'SQLITE', sqlitePath: form.value.sqlitePath }
}
return {
type: 'MYSQL',
host: form.value.host,
port: form.value.port,
database: form.value.database,
username: form.value.username,
// 留空表示沿用已保存的密码
password: form.value.password || undefined,
}
}
/** 前端必填校验 */
function validate(): boolean {
if (form.value.type === 'SQLITE') {
if (!form.value.sqlitePath?.trim()) {
message.warning(t('database.sqlitePathRequired'))
return false
}
return true
}
if (!form.value.host?.trim() || !form.value.database?.trim() || !form.value.username?.trim()) {
message.warning(t('database.mysqlRequired'))
return false
}
return true
}
/** 测试数据库连接 */
async function handleTest() {
if (!validate()) return
testing.value = true
try {
await testDatabaseConnection(buildPayload())
message.success(t('database.testSuccess'))
} catch (e: any) {
message.error(e.message || t('database.testFailed'))
} finally {
testing.value = false
}
}
/** 保存配置并切换到新数据库 */
async function handleSave() {
if (!validate()) return
saving.value = true
try {
const res: any = await saveDatabaseConfig(buildPayload())
databaseStore.setStatus(res.data)
message.success(t('database.saveSuccess'))
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
await loadStatus()
}
} catch (e: any) {
message.error(e.message || t('database.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadStatus)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="ServerOutline" />
<span>{{ props.setup ? t('database.setupTitle') : t('settings.databaseSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="configured ? 'success' : 'warning'" size="small" :bordered="false">
{{ configured ? t('database.configured') : t('database.notConfigured') }}
</NTag>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ props.setup ? t('database.setupIntro') : t('database.intro') }}
</NAlert>
<!-- 当前配置概览 -->
<NDescriptions v-if="configured" :column="1" label-placement="left" size="small" class="db-current">
<NDescriptionsItem :label="t('database.currentType')">
<NTag size="small" :bordered="false">{{ databaseStore.status?.typeName }}</NTag>
</NDescriptionsItem>
<NDescriptionsItem :label="t('database.currentSummary')">
<NText code>{{ databaseStore.status?.summary }}</NText>
</NDescriptionsItem>
<NDescriptionsItem :label="t('database.configFile')">
<NText depth="3" style="font-size: 12px;">{{ databaseStore.status?.configFile }}</NText>
</NDescriptionsItem>
</NDescriptions>
<NForm label-placement="left" label-width="120">
<NFormItem :label="t('database.type')">
<NRadioGroup v-model:value="form.type">
<NRadioButton value="SQLITE">SQLite</NRadioButton>
<NRadioButton value="MYSQL">MySQL</NRadioButton>
</NRadioGroup>
</NFormItem>
<!-- SQLite 配置 -->
<template v-if="form.type === 'SQLITE'">
<NFormItem :label="t('database.sqlitePath')">
<NInput v-model:value="form.sqlitePath" :placeholder="t('database.sqlitePathPlaceholder')">
<template #prefix>
<NIcon :component="FolderOpenOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem>
<NText depth="3" style="font-size: 12px;">{{ t('database.sqliteHint') }}</NText>
</NFormItem>
</template>
<!-- MySQL 配置 -->
<template v-else>
<NFormItem :label="t('database.host')">
<NInput v-model:value="form.host" placeholder="localhost" />
</NFormItem>
<NFormItem :label="t('database.port')">
<NInputNumber v-model:value="form.port" :min="1" :max="65535" style="width: 100%;" />
</NFormItem>
<NFormItem :label="t('database.database')">
<NInput v-model:value="form.database" placeholder="finance" />
</NFormItem>
<NFormItem :label="t('database.username')">
<NInput v-model:value="form.username" placeholder="root" />
</NFormItem>
<NFormItem :label="t('database.password')">
<NInput
v-model:value="form.password"
type="password"
show-password-on="click"
:placeholder="hasPassword ? t('database.passwordKeep') : t('database.passwordPlaceholder')"
/>
</NFormItem>
</template>
<NFormItem>
<NSpace>
<NButton :loading="testing" @click="handleTest">
<template #icon>
<NIcon :component="FlashOutline" />
</template>
{{ t('database.test') }}
</NButton>
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('database.save') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NCard>
</template>
<style scoped>
.db-current {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
</style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 注册设置面板:控制系统是否开放注册。系统无任何用户时始终允许创建首个账号。
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText, NTag, NAlert,
} from 'naive-ui'
import { PersonAddOutline, SaveOutline } from '@vicons/ionicons5'
import { getRegistrationStatus, updateRegistration } from '../api/user'
import { useRegistrationStore } from '../store/registration'
const message = useMessage()
const { t } = useI18n()
const registrationStore = useRegistrationStore()
const enabled = ref(false)
const saving = ref(false)
const loading = ref(false)
/** 加载当前注册状态并回显开关 */
async function loadStatus() {
loading.value = true
try {
const res: any = await getRegistrationStatus()
registrationStore.setStatus(res.data)
enabled.value = !!res.data?.enabled
} catch (e: any) {
message.error(e.message || t('registration.loadFailed'))
} finally {
loading.value = false
}
}
/** 保存注册开关 */
async function handleSave() {
saving.value = true
try {
const res: any = await updateRegistration(enabled.value)
registrationStore.setStatus(res.data)
message.success(t('registration.saveSuccess'))
} catch (e: any) {
message.error(e.message || t('registration.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadStatus)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="PersonAddOutline" />
<span>{{ t('settings.registrationSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ enabled ? t('registration.statusOn') : t('registration.statusOff') }}
</NTag>
</template>
<NAlert v-if="registrationStore.status?.firstUser" type="warning" :show-icon="true" style="margin-bottom: 16px;">
{{ t('registration.firstUserHint') }}
</NAlert>
<div class="switch-row">
<NSpace align="center" :size="12">
<NSwitch v-model:value="enabled" :disabled="loading" />
<NText depth="3" style="font-size: 12px;">{{ t('registration.enabledHint') }}</NText>
</NSpace>
</div>
<NSpace style="margin-top: 16px;">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('registration.save') }}
</NButton>
</NSpace>
</NCard>
</template>
<style scoped>
.switch-row {
display: block;
}
</style>
+67 -5
View File
@@ -82,11 +82,72 @@ export default {
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
aiSettings: { zh: 'AI 设置', en: 'AI Settings', ja: 'AI 設定' },
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
logout: { zh: '退出', en: 'Sign Out', ja: 'ログアウト' },
},
// 数据库设置
database: {
title: { zh: '数据库设置', en: 'Database Settings', ja: 'データベース設定' },
setupTitle: { zh: '初始化数据库', en: 'Initialize Database', ja: 'データベースの初期化' },
intro: { zh: '可选择本地 SQLite 文件数据库或 MySQL 数据库。保存后立即生效并自动建表,原有数据库的数据保持不变。', en: 'Choose a local SQLite file database or a MySQL database. Changes take effect immediately with automatic table creation; data in the previous database is preserved.', ja: 'ローカルの SQLite ファイルデータベースまたは MySQL を選択できます。保存すると即時に反映され、テーブルが自動作成されます。以前のデータベースのデータは保持されます。' },
setupIntro: { zh: '首次使用请先完成数据库配置。推荐零配置的 SQLite(本地文件),也可以连接已有的 MySQL。', en: 'Please configure a database before first use. SQLite (local file) requires no setup; you may also connect to an existing MySQL.', ja: '初回利用の前にデータベースを設定してください。設定不要の SQLite(ローカルファイル)を推奨します。既存の MySQL に接続することもできます。' },
setupHint: { zh: '配置完成后即可登录使用;如需修改,可稍后在「数据库设置」中调整。', en: 'After configuration you can sign in. You can adjust it later in Database Settings.', ja: '設定後はログインして利用できます。後で「データベース設定」から変更できます。' },
configured: { zh: '已配置', en: 'Configured', ja: '設定済み' },
notConfigured: { zh: '未配置', en: 'Not configured', ja: '未設定' },
currentType: { zh: '当前类型', en: 'Current type', ja: '現在の種類' },
currentSummary: { zh: '连接地址', en: 'Connection', ja: '接続先' },
configFile: { zh: '配置文件', en: 'Config file', ja: '設定ファイル' },
type: { zh: '数据库类型', en: 'Database type', ja: 'データベースの種類' },
sqlitePath: { zh: '数据库文件', en: 'Database file', ja: 'データベースファイル' },
sqlitePathPlaceholder: { zh: '例如 data/finance.db', en: 'e.g. data/finance.db', ja: '例:data/finance.db' },
sqliteHint: { zh: 'SQLite 为本地单文件数据库,路径相对于服务运行目录;建议使用 data 目录并做好持久化挂载。', en: 'SQLite is a single local file; the path is relative to the server working directory. Use a data directory with persistent storage.', ja: 'SQLite はローカルの単一ファイルです。パスはサーバーの作業ディレクトリからの相対パスです。data ディレクトリと永続化ストレージの利用を推奨します。' },
sqlitePathRequired: { zh: '请填写 SQLite 数据库文件路径', en: 'Please enter the SQLite database file path', ja: 'SQLite データベースファイルのパスを入力してください' },
mysqlRequired: { zh: '请填写主机地址、数据库名与用户名', en: 'Please fill in host, database and username', ja: 'ホスト、データベース名、ユーザー名を入力してください' },
host: { zh: '主机地址', en: 'Host', ja: 'ホスト' },
port: { zh: '端口', en: 'Port', ja: 'ポート' },
database: { zh: '数据库名', en: 'Database', ja: 'データベース名' },
username: { zh: '用户名', en: 'Username', ja: 'ユーザー名' },
password: { zh: '密码', en: 'Password', ja: 'パスワード' },
passwordPlaceholder: { zh: '请输入密码', en: 'Enter password', ja: 'パスワードを入力してください' },
passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged', ja: '設定済み。空欄のままにすると変更されません' },
test: { zh: '测试连接', en: 'Test connection', ja: '接続テスト' },
save: { zh: '保存并应用', en: 'Save & Apply', ja: '保存して適用' },
testSuccess: { zh: '连接成功', en: 'Connection succeeded', ja: '接続に成功しました' },
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
saveSuccess: { zh: '数据库配置已保存并生效', en: 'Database configuration saved and applied', ja: 'データベース設定を保存して適用しました' },
saveFailed: { zh: '数据库配置失败', en: 'Failed to configure database', ja: 'データベース設定に失敗しました' },
loadFailed: { zh: '加载数据库状态失败', en: 'Failed to load database status', ja: 'データベース状態の読み込みに失敗しました' },
},
// 系统设置
settings: {
title: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
databaseSection: { zh: '数据库配置', en: 'Database', ja: 'データベース' },
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
},
// 注册设置
registration: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
enabledHint: { zh: '关闭后新用户无法注册;系统无任何用户时仍可创建首个账号', en: 'When off, new users cannot sign up; the first account can still be created when there are no users.', ja: 'オフにすると新規登録できません。ユーザーが一人もいない場合は最初のアカウントを作成できます。' },
firstUserHint: { zh: '当前系统暂无用户,可直接注册首个账号', en: 'No users yet — you can register the first account directly.', ja: '現在ユーザーがいないため、最初のアカウントを直接登録できます。' },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '注册设置已保存', en: 'Registration settings saved', ja: '登録設定を保存しました' },
saveFailed: { zh: '保存注册设置失败', en: 'Failed to save registration settings', ja: '登録設定の保存に失敗しました' },
loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' },
},
// 模型使用情况
aiUsage: {
title: { zh: '模型使用情况', en: 'Model Usage', ja: 'モデル利用状況' },
back: { zh: '返回设置', en: 'Back to Settings', ja: '設定に戻る' },
},
// 统计面板
dashboard: {
title: { zh: '统计面板', en: 'Dashboard', ja: 'ダッシュボード' },
@@ -312,9 +373,7 @@ export default {
// AI 智能建议
ai: {
title: { zh: 'AI 智能建议', en: 'AI Suggestions', ja: 'AI 提案' },
intro: { zh: '配置 DeepSeek 后,可在收支记录页的「一键设置」中让 AI 根据记录内容推荐分类与标签。', en: 'After configuring DeepSeek, you can let AI recommend categories and tags for records in the "Batch Set" dialog on the Transactions page.', ja: 'DeepSeek を設定すると、収支記録ページの「一括設定」で AI が記録内容に応じてカテゴリとタグを提案できます。' },
configTitle: { zh: '模型配置', en: 'Model Configuration', ja: 'モデル設定' },
intro: { zh: '启用后,可在收支记录页使用 AI 智能建议(一键设置中推荐分类与标签)、智能记账与 AI 查询;调用量与 Token 消耗可在「模型使用情况」查看。', en: 'Once enabled, you can use AI suggestions (categories & tags in Batch Set), Smart Entry, and AI Query on the Transactions page; call counts and token usage are available under "Model Usage".', ja: '有効にすると、収支記録ページで AI 提案(一括設定でカテゴリとタグを推薦)、スマート入力、AI 検索を利用できます。呼び出し回数とトークン消費は「モデル利用状況」で確認できます。' },
enabledOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
enabledOff: { zh: '未启用', en: 'Disabled', ja: '無効' },
enabled: { zh: '启用 AI 建议', en: 'Enable AI suggestions', ja: 'AI 提案を有効にする' },
@@ -349,7 +408,10 @@ export default {
oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied', ja: 'AI が提案したカテゴリを適用しました' },
tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied', ja: 'AI が提案したタグを適用しました' },
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions', ja: '提案の適用に失敗しました' },
applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.', ja: 'カテゴリは上書きされ、タグは追加されます。確定するとすぐに保存されます。' },
applyHint: { zh: '将覆盖分类并追加标签;标记「新建」的分类/标签会在确认后创建', en: 'Categories will be overwritten and tags appended; items marked "New" will be created after confirmation.', ja: 'カテゴリは上書き、タグは追加されます。「新規」の項目は確定後に作成されます。' },
newBadge: { zh: '新建', en: 'New', ja: '新規' },
applyNewTitle: { zh: '确认新建', en: 'Confirm creation', ja: '新規作成の確認' },
applyNewConfirm: { zh: '将新建 {categories} 个分类、{tags} 个标签,确认应用?', en: 'Will create {categories} category(ies) and {tags} tag(s). Continue?', ja: '{categories} 件のカテゴリと {tags} 件のタグを作成します。適用しますか?' },
needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.', ja: 'AI が有効になっていません。「AI 設定」で設定してください。' },
decorate: {
emojiButton: { zh: 'AI 推荐', en: 'AI Pick', ja: 'AI におまかせ' },
+52 -5
View File
@@ -1,11 +1,18 @@
// 路由配置文件:定义应用的所有路由及导航守卫
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../store/user'
import { useDatabaseStore } from '../store/database'
import { useRegistrationStore } from '../store/registration'
// 创建路由实例,使用 HTML5 History 模式
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/database-setup',
name: 'DatabaseSetup',
component: () => import('../views/DatabaseSettings.vue'), // 首次数据库配置引导页(无需登录)
},
{
path: '/login',
name: 'Login',
@@ -42,17 +49,57 @@ const router = createRouter({
component: () => import('../views/TagManage.vue'), // 标签管理
},
{
path: 'ai-settings',
name: 'AiSettings',
component: () => import('../views/AiSettings.vue'), // AI 设置
path: 'settings',
name: 'Settings',
component: () => import('../views/Settings.vue'), // 系统设置(数据库 + AI 模型)
},
{
path: 'ai-usage',
name: 'AiUsage',
component: () => import('../views/AiUsage.vue'), // 模型使用情况
},
],
},
],
})
// 全局前置守卫:未登录时拦截跳转至登录页
router.beforeEach((to, _from, next) => {
// 全局前置守卫:优先校验数据库配置,再校验登录状态
router.beforeEach(async (to, _from, next) => {
const databaseStore = useDatabaseStore()
// 首次进入时从后端加载数据库配置状态
if (!databaseStore.loaded) {
await databaseStore.load()
}
// 数据库未配置:强制引导到配置页,避免用户卡在无法登录的状态
if (!databaseStore.configured) {
if (to.path === '/database-setup') {
next()
} else {
next('/database-setup')
}
return
}
// 已配置数据库:配置引导页不再需要,跳转到登录或首页
if (to.path === '/database-setup') {
const userStore = useUserStore()
next(userStore.token ? '/dashboard' : '/login')
return
}
// 注册功能关闭且已有用户时,不允许访问注册页
if (to.path === '/register') {
const registrationStore = useRegistrationStore()
if (!registrationStore.loaded) {
await registrationStore.load()
}
if (!registrationStore.status?.allowed) {
next('/login')
return
}
}
const userStore = useUserStore()
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
+32
View File
@@ -0,0 +1,32 @@
// 数据库状态管理模块:记录后端是否已完成数据库配置,供路由守卫与页面使用
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDatabaseStatus } from '../api/database'
import type { DatabaseStatus } from '../api/database'
export const useDatabaseStore = defineStore('database', () => {
const configured = ref(false) // 后端是否已完成数据库配置
const status = ref<DatabaseStatus | null>(null) // 当前数据库状态
const loaded = ref(false) // 是否已从后端加载过状态
/** 从后端加载数据库状态 */
async function load() {
try {
const res: any = await getDatabaseStatus()
setStatus(res.data)
} catch (e) {
// 状态查询失败时按未配置处理,引导用户前往配置页
configured.value = false
loaded.value = true
}
}
/** 更新本地保存的数据库状态 */
function setStatus(next: DatabaseStatus | null) {
status.value = next
configured.value = !!next?.configured
loaded.value = true
}
return { configured, status, loaded, load, setStatus }
})
+30
View File
@@ -0,0 +1,30 @@
// 注册状态管理模块:缓存后端注册开关与是否允许注册,供登录页与路由守卫复用
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getRegistrationStatus } from '../api/user'
import type { RegistrationStatus } from '../api/user'
export const useRegistrationStore = defineStore('registration', () => {
const status = ref<RegistrationStatus | null>(null) // 注册状态
const loaded = ref(false) // 是否已从后端加载过
/** 从后端加载注册状态,失败时按不允许注册处理 */
async function load() {
try {
const res: any = await getRegistrationStatus()
status.value = res.data
} catch (e) {
status.value = null
} finally {
loaded.value = true
}
}
/** 更新本地缓存的注册状态 */
function setStatus(next: RegistrationStatus | null) {
status.value = next
loaded.value = true
}
return { status, loaded, load, setStatus }
})
-379
View File
@@ -1,379 +0,0 @@
<script setup lang="ts">
// AI 设置页:配置 DeepSeek 的启用开关、API Key、基础地址与模型,并展示 AI 使用情况
import { ref, onMounted, computed, h } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NSwitch, NButton, NSpace, NAlert, NText,
NStatistic, NDataTable, NTag, NGrid, NGridItem, NIcon, NEmpty,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
PulseOutline, CheckmarkCircleOutline, CloseCircleOutline, StatsChartOutline,
CloudUploadOutline, CloudDownloadOutline, FlashOutline, TimeOutline,
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline,
} from '@vicons/ionicons5'
import { getAiConfig, saveAiConfig, testAiConfig, getAiUsage } from '../api/ai'
import type { AiUsageResult } from '../api/ai'
const message = useMessage() // 消息提示
const { t } = useI18n() // 国际化
const saving = ref(false) // 保存状态
const testing = ref(false) // 测试连接状态
const hasApiKey = ref(false) // 是否已配置过 API Key
const usage = ref<AiUsageResult | null>(null) // AI 使用情况
const usageLoading = ref(false) // 使用情况加载状态
// 表单数据
const form = ref({ enabled: false, apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' })
/** 加载当前用户的 AI 配置 */
async function loadConfig() {
try {
const res: any = await getAiConfig()
const data = res.data
form.value = {
enabled: !!data.enabled,
apiKey: '',
baseUrl: data.baseUrl || 'https://api.deepseek.com',
model: data.model || 'deepseek-chat',
}
hasApiKey.value = !!data.hasApiKey
} catch (e: any) {
message.error(e.message || t('ai.loadFailed'))
}
}
/** 加载 AI 使用情况(累计汇总 + 最近明细) */
async function loadUsage() {
usageLoading.value = true
try {
const res: any = await getAiUsage()
usage.value = res.data
} catch (e: any) {
message.error(e.message || t('ai.usage.loadFailed'))
} finally {
usageLoading.value = false
}
}
/** 提交表单数据,apiKey 为空时不覆盖已保存的 Key */
function buildPayload() {
return {
enabled: form.value.enabled,
apiKey: form.value.apiKey || undefined,
baseUrl: form.value.baseUrl,
model: form.value.model,
}
}
/** 保存配置 */
async function handleSave() {
saving.value = true
try {
await saveAiConfig(buildPayload())
message.success(t('ai.saveSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
saving.value = false
}
}
/** 测试连接:先保存当前表单,再向 DeepSeek 发起测试请求 */
async function handleTest() {
testing.value = true
try {
await saveAiConfig(buildPayload())
await testAiConfig()
message.success(t('ai.testSuccess'))
await loadConfig()
} catch (e: any) {
message.error(e.message || t('ai.testFailed'))
} finally {
testing.value = false
// 测试连接也会产生使用记录,刷新使用情况
await loadUsage()
}
}
/** 数字千分位格式化 */
function formatNumber(value: number | null | undefined) {
return value == null ? '0' : Number(value).toLocaleString()
}
/** 使用概览卡片配置 */
const overviewStats = computed(() => [
{ key: 'calls', label: t('ai.usage.totalCalls'), value: usage.value?.summary.totalCalls ?? 0, icon: PulseOutline, color: '#2080f0' },
{ key: 'success', label: t('ai.usage.successCalls'), value: usage.value?.summary.successCalls ?? 0, icon: CheckmarkCircleOutline, color: '#18a058' },
{ key: 'failed', label: t('ai.usage.failedCalls'), value: usage.value?.summary.failedCalls ?? 0, icon: CloseCircleOutline, color: '#d03050' },
{ key: 'total', label: t('ai.usage.totalTokens'), value: usage.value?.summary.totalTokens ?? 0, icon: StatsChartOutline, color: '#7c3aed' },
])
/** Token 明细卡片配置 */
const tokenStats = computed(() => [
{ key: 'prompt', label: t('ai.usage.promptTokens'), value: usage.value?.summary.promptTokens ?? 0, icon: CloudUploadOutline, color: '#2080f0' },
{ key: 'completion', label: t('ai.usage.completionTokens'), value: usage.value?.summary.completionTokens ?? 0, icon: CloudDownloadOutline, color: '#f0a020' },
{ key: 'cached', label: t('ai.usage.cachedTokens'), value: usage.value?.summary.cachedTokens ?? 0, icon: FlashOutline, color: '#18a058' },
])
/** 使用明细表格列定义(随语言切换响应式更新) */
const usageColumns = computed<DataTableColumns<any>>(() => [
{
title: t('ai.usage.column.time'), key: 'createdAt', width: 160,
render: (row) => {
if (!row.createdAt) return '-'
const [date, time] = String(row.createdAt).replace('T', ' ').split(' ')
return h('div', { class: 'cell-time' }, [
h('div', null, date),
h('div', { class: 'cell-time-sub' }, (time || '').substring(0, 8)),
])
},
},
{
title: t('ai.usage.column.type'), key: 'type', width: 120,
render: (row) => h(NTag, { type: row.type === 'SUGGEST' ? 'info' : 'default', size: 'small', bordered: false },
{ default: () => t(`ai.usage.type.${String(row.type).toLowerCase()}`) }),
},
{
title: t('ai.usage.column.count'), key: 'transactionCount', width: 100, align: 'right', titleAlign: 'right',
render: (row) => formatNumber(row.transactionCount),
},
{
title: t('ai.usage.column.promptTokens'), key: 'promptTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.promptTokens)),
},
{
title: t('ai.usage.column.completionTokens'), key: 'completionTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num' }, formatNumber(row.completionTokens)),
},
{
title: t('ai.usage.column.totalTokens'), key: 'totalTokens', width: 100, align: 'right', titleAlign: 'right',
render: (row) => h('span', { class: 'cell-num cell-total' }, formatNumber(row.totalTokens)),
},
{
title: t('ai.usage.column.duration'), key: 'durationMs', width: 100, align: 'right', titleAlign: 'right',
render: (row) => (row.durationMs != null ? `${formatNumber(row.durationMs)} ms` : '-'),
},
{
title: t('ai.usage.column.status'), key: 'success', width: 110, align: 'center', titleAlign: 'center',
render: (row) => (row.success
? h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => t('ai.usage.success') })
: h('span', { title: row.errorMessage || '' },
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => t('ai.usage.failed') }))),
},
])
// 页面挂载时加载配置与使用情况
onMounted(() => {
loadConfig()
loadUsage()
})
</script>
<template>
<div>
<h2 style="margin: 0 0 16px;">{{ t('ai.title') }}</h2>
<!-- 模型配置 -->
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SettingsOutline" />
<span>{{ t('ai.configTitle') }}</span>
</NSpace>
</template>
<template #header-extra>
<NTag :type="form.enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ form.enabled ? t('ai.enabledOn') : t('ai.enabledOff') }}
</NTag>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ t('ai.intro') }}
</NAlert>
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('ai.enabled')">
<div class="switch-row">
<NSwitch v-model:value="form.enabled" />
<NText depth="3" style="font-size: 12px;">{{ t('ai.enabledHint') }}</NText>
</div>
</NFormItem>
<NFormItem :label="t('ai.apiKey')">
<NInput
v-model:value="form.apiKey"
type="password"
show-password-on="click"
:placeholder="hasApiKey ? t('ai.apiKeyKeep') : t('ai.apiKeyPlaceholder')"
>
<template #prefix>
<NIcon :component="KeyOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.baseUrl')">
<NInput v-model:value="form.baseUrl" placeholder="https://api.deepseek.com">
<template #prefix>
<NIcon :component="LinkOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem :label="t('ai.model')">
<NInput v-model:value="form.model" placeholder="deepseek-chat">
<template #prefix>
<NIcon :component="CubeOutline" />
</template>
</NInput>
</NFormItem>
<NFormItem>
<NSpace>
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('ai.save') }}
</NButton>
<NButton :loading="testing" :disabled="!form.enabled" @click="handleTest">
<template #icon>
<NIcon :component="FlashOutline" />
</template>
{{ t('ai.test') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
</NCard>
<!-- 使用情况:累计汇总 + 最近调用明细 -->
<NCard :bordered="false" class="usage-card">
<template #header>
<NSpace justify="space-between" align="center" style="width: 100%;">
<NSpace align="center" :size="8">
<NIcon :component="StatsChartOutline" />
<span>{{ t('ai.usage.title') }}</span>
</NSpace>
<NButton size="small" :loading="usageLoading" @click="loadUsage">
<template #icon>
<NIcon :component="TimeOutline" />
</template>
{{ t('ai.usage.refresh') }}
</NButton>
</NSpace>
</template>
<!-- 调用概览 -->
<NGrid cols="2 m:4" :x-gap="12" :y-gap="12">
<NGridItem v-for="item in overviewStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- Token 明细 -->
<NGrid cols="1 s:3" :x-gap="12" :y-gap="12" style="margin-top: 12px;">
<NGridItem v-for="item in tokenStats" :key="item.key">
<div class="stat-card">
<div class="stat-icon" :style="{ background: item.color + '1a', color: item.color }">
<NIcon size="20"><component :is="item.icon" /></NIcon>
</div>
<NStatistic
:label="item.label"
:value="formatNumber(item.value)"
:value-style="{ color: item.color, fontSize: '20px', fontWeight: 600 }"
/>
</div>
</NGridItem>
</NGrid>
<!-- 最近调用明细 -->
<div class="table-title">{{ t('ai.usage.recentTitle') }}</div>
<NDataTable
:columns="usageColumns"
:data="usage?.recent ?? []"
:loading="usageLoading"
size="small"
:bordered="false"
:striped="true"
class="usage-table"
>
<template #empty>
<NEmpty :description="t('ai.usage.empty')" />
</template>
</NDataTable>
</NCard>
</div>
</template>
<style scoped>
.config-card {
max-width: 720px;
}
.usage-card {
max-width: 960px;
margin-top: 16px;
}
.switch-row {
display: flex;
align-items: center;
gap: 12px;
}
.table-title {
margin: 22px 0 10px;
font-size: 13px;
font-weight: 600;
color: #666;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.02);
transition: background 0.2s ease;
}
.stat-card:hover {
background: rgba(0, 0, 0, 0.04);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
flex-shrink: 0;
}
.cell-time {
line-height: 1.35;
}
.cell-time-sub {
color: #999;
font-size: 12px;
}
.cell-num {
font-variant-numeric: tabular-nums;
}
.cell-total {
font-weight: 600;
}
/* 表头与行视觉优化 */
.usage-table :deep(.n-data-table-th) {
background-color: rgba(0, 0, 0, 0.025);
font-weight: 600;
font-size: 12px;
}
.usage-table :deep(.n-data-table-td) {
font-variant-numeric: tabular-nums;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
// 模型使用情况页:展示 AI 调用的累计汇总与最近明细
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NSpace, NButton, NIcon } from 'naive-ui'
import { ArrowBackOutline } from '@vicons/ionicons5'
import AiUsagePanel from '../components/AiUsagePanel.vue'
const router = useRouter()
const { t } = useI18n()
</script>
<template>
<div class="usage-page">
<NSpace align="center" justify="space-between" style="margin-bottom: 16px;">
<h2 class="page-title">{{ t('aiUsage.title') }}</h2>
<NButton size="small" @click="router.push('/settings')">
<template #icon>
<NIcon :component="ArrowBackOutline" />
</template>
{{ t('aiUsage.back') }}
</NButton>
</NSpace>
<AiUsagePanel />
</div>
</template>
<style scoped>
.usage-page {
max-width: 960px;
}
.page-title {
margin: 0;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
// 首次数据库配置引导页:居中展示数据库配置面板,保存成功后跳转登录
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
</script>
<template>
<div class="setup-wrapper">
<div class="setup-card">
<DatabaseConfigPanel setup />
</div>
</div>
</template>
<style scoped>
.setup-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
box-sizing: border-box;
}
.setup-card {
width: 100%;
max-width: 720px;
}
.setup-card :deep(.n-card) {
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
}
</style>
+5 -5
View File
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SparklesOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -45,14 +45,14 @@ function handleMenuUpdate(key: string) {
// 顶部用户下拉菜单配置项(随语言切换响应式更新)
const userMenuOptions = computed(() => [
{ label: t('layout.aiSettings'), key: 'ai-settings', icon: renderIcon(SparklesOutline) },
{ label: t('layout.settings'), key: 'settings', icon: renderIcon(SettingsOutline) },
{ label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) },
])
/** 用户下拉菜单选择事件:跳转 AI 设置或退出登录 */
/** 用户下拉菜单选择事件:跳转系统设置或退出登录 */
function handleUserMenuSelect(key: string) {
if (key === 'ai-settings') {
router.push('/ai-settings')
if (key === 'settings') {
router.push('/settings')
} else if (key === 'logout') {
handleLogout()
}
+13 -2
View File
@@ -1,18 +1,29 @@
<script setup lang="ts">
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
import { login } from '../api/user'
import { useUserStore } from '../store/user'
import { useRegistrationStore } from '../store/registration'
import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter()
const message = useMessage() // 消息提示工具
const userStore = useUserStore() // 用户状态管理
const registrationStore = useRegistrationStore() // 注册状态管理
const { t } = useI18n() // 国际化
// 是否显示注册入口:注册开关开启或系统尚无用户时显示
const canRegister = computed(() => registrationStore.status?.allowed ?? false)
onMounted(async () => {
if (!registrationStore.loaded) {
await registrationStore.load()
}
})
// 登录表单数据
const form = ref({
username: '',
@@ -56,7 +67,7 @@ async function handleLogin() {
</NFormItem>
<NSpace vertical>
<NButton type="primary" block :loading="loading" @click="handleLogin">{{ t('login.submit') }}</NButton>
<NButton text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
<NButton v-if="canRegister" text type="info" @click="router.push('/register')">{{ t('login.toRegister') }}</NButton>
</NSpace>
</NForm>
</NCard>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
// 系统设置页:上下分区展示数据库配置与 AI 模型配置
import { useI18n } from 'vue-i18n'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import AiConfigPanel from '../components/AiConfigPanel.vue'
const { t } = useI18n()
</script>
<template>
<div class="settings-page">
<h2 class="page-title">{{ t('settings.title') }}</h2>
<DatabaseConfigPanel />
<RegistrationConfigPanel />
<AiConfigPanel />
</div>
</template>
<style scoped>
.settings-page {
max-width: 860px;
}
.page-title {
margin: 0 0 16px;
}
.settings-page > :deep(.n-card) {
margin-bottom: 16px;
}
</style>
+95 -4
View File
@@ -1007,17 +1007,47 @@ async function handleAiApply() {
message.warning(t('ai.applyNone'))
return
}
// 勾选中包含新建分类/标签时,先经用户确认再落库
if (aiNewCategoryNames.value.length > 0 || aiNewTagNames.value.length > 0) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.warning({
title: t('ai.applyNewTitle'),
content: t('ai.applyNewConfirm', {
categories: aiNewCategoryNames.value.length,
tags: aiNewTagNames.value.length,
}),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
if (!confirmed) {
return
}
}
aiApplying.value = true
try {
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
const payload = chosen.flatMap((s) => {
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
return ids.map((id) => ({ transactionId: id, categoryId: s.categoryId, tagIds: s.tagIds || [] }))
return ids.map((id) => ({
transactionId: id,
categoryId: s.categoryId,
newCategoryName: s.newCategoryName,
newCategoryIcon: s.newCategoryIcon,
tagIds: s.tagIds || [],
newTags: s.newTags || [],
}))
})
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showAiModal.value = false
await fetchData()
// 应用过程中可能新建了分类或标签,刷新以保持一致
await fetchCategories()
await fetchTags()
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
@@ -1344,6 +1374,30 @@ const aiTotalCount = computed(() => {
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
})
/** 勾选建议中需要新建的分类名称(去重) */
const aiNewCategoryNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newCategoryName) {
names.add(s.newCategoryName)
}
})
return Array.from(names)
})
/** 勾选建议中需要新建的标签名称(去重) */
const aiNewTagNames = computed(() => {
const selected = new Set(checkedAiRowKeys.value)
const names = new Set<string>()
aiSuggestions.value.forEach((s) => {
if (selected.has(s.transactionId) && s.newTags) {
s.newTags.forEach((tag) => names.add(tag.name))
}
})
return Array.from(names)
})
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
const aiColumns = computed<DataTableColumns<any>>(() => {
const columns: DataTableColumns<any> = [
@@ -1365,18 +1419,55 @@ const aiColumns = computed<DataTableColumns<any>>(() => {
]
// 按建议范围展示对应列
if (aiScope.value !== 'TAG') {
columns.push({ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' })
columns.push({
title: t('ai.column.category'), key: 'category', minWidth: 150,
render: (row) => renderCategoryCell(row),
})
}
if (aiScope.value !== 'CATEGORY') {
columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
title: t('ai.column.tags'), key: 'tags', minWidth: 180,
render: (row) => renderTagsCell(row),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 渲染 AI 建议的分类单元格,新建分类带「新建」标记 */
function renderCategoryCell(row: AiSuggestion) {
if (row.newCategoryName) {
return h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NTag, { size: 'small', type: 'info' }, { default: () => t('ai.newBadge') }),
h('span', null, `${row.newCategoryIcon || ''}${row.newCategoryName}`),
],
})
}
return row.categoryName || '-'
}
/** 渲染 AI 建议的标签单元格,新建标签带「新建」标记并使用建议颜色 */
function renderTagsCell(row: AiSuggestion) {
const items: any[] = []
if (row.tagNames && row.tagNames.length > 0) {
items.push(...row.tagNames.map((name) => h(NTag, { size: 'small' }, { default: () => name })))
}
if (row.newTags && row.newTags.length > 0) {
items.push(...row.newTags.map((tag) =>
h(NTag, { size: 'small', color: { color: tag.color, textColor: '#fff' } }, {
default: () => h(NSpace, { size: 2, align: 'center', wrap: false }, {
default: () => [
h('span', null, t('ai.newBadge')),
h('span', null, tag.name),
],
}),
}),
))
}
return items.length > 0 ? h(NSpace, { size: 4, wrap: true }, { default: () => items }) : '-'
}
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.13-bin.zip
networkTimeout=120000
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94 -94
View File
@@ -1,94 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1 -1
View File
@@ -1 +1 @@
rootProject.name = 'my-app'
rootProject.name = 'finance-app'
@@ -2,12 +2,16 @@ package cn.rosercode.finance;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
/**
* 应用程序主启动类。
* <p>Spring Boot 应用的入口点,负责引导和启动整个应用程序。</p>
*
* <p>排除 {@link DataSourceAutoConfiguration},因为数据源改为运行时配置:
* 应用在未配置数据库时也能正常启动,由动态数据源提供占位连接。</p>
*/
@SpringBootApplication
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class FinanceApplication {
/**
@@ -0,0 +1,22 @@
package cn.rosercode.finance.common;
/**
* 数据库未配置异常。
*
* <p>当应用尚未完成数据库配置、业务代码却尝试访问数据库时抛出。全局异常处理器会将其
* 转换为携带专用状态码的响应,前端据此跳转到数据库配置引导页。</p>
*
* @date 2026-09-18 11:00:23
*/
public class DatabaseNotConfiguredException extends BusinessException {
/** 数据库未配置的专用状态码 */
public static final int CODE = 5031;
/**
* 构造数据库未配置异常。
*/
public DatabaseNotConfiguredException() {
super(CODE, "数据库尚未配置,请先完成数据库配置");
}
}
@@ -1,8 +1,15 @@
package cn.rosercode.finance.common;
import cn.rosercode.finance.config.SpaRouteSupport;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.io.IOException;
/**
* 全局异常处理器。
@@ -22,6 +29,17 @@ public class GlobalExceptionHandler {
return Result.error(e.getCode(), e.getMessage());
}
/**
* 处理数据库未配置异常。
*
* @param e 数据库未配置异常
* @return 包含专用状态码的响应结果
*/
@ExceptionHandler(DatabaseNotConfiguredException.class)
public Result<Void> handleDatabaseNotConfigured(DatabaseNotConfiguredException e) {
return Result.error(e.getCode(), e.getMessage());
}
/**
* 处理参数校验异常。
* <p>当请求参数校验不通过时,提取第一个校验错误信息并返回。</p>
@@ -36,6 +54,31 @@ public class GlobalExceptionHandler {
return Result.error(400, message);
}
/**
* 处理静态资源未找到异常。
*
* <p>前后端一体化部署时,前端 History 路由(如 {@code /dashboard})在服务器上无对应文件,
* Spring 会抛出该异常。此时转发到前端入口页面,由前端路由接管;其余情况返回 404。</p>
*
* @param e 静态资源未找到异常
* @param request HTTP 请求
* @param response HTTP 响应
* @return SPA 路由转发时返回 null,否则返回 404 错误结果
* @throws IOException 转发时可能抛出
* @throws ServletException 转发时可能抛出
*/
@ExceptionHandler(NoResourceFoundException.class)
public Object handleNoResourceFound(NoResourceFoundException e,
HttpServletRequest request,
HttpServletResponse response) throws IOException, ServletException {
String path = request.getRequestURI();
if (SpaRouteSupport.isSpaRoute(path)) {
request.getRequestDispatcher("/index.html").forward(request, response);
return null;
}
return Result.error(404, "资源不存在");
}
/**
* 处理未捕获的通用异常。
*
@@ -44,8 +87,36 @@ public class GlobalExceptionHandler {
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
// 数据库未配置异常可能被持久层框架包装,需沿异常链查找
DatabaseNotConfiguredException notConfigured =
findCause(e, DatabaseNotConfiguredException.class);
if (notConfigured != null) {
return Result.error(notConfigured.getCode(), notConfigured.getMessage());
}
// 打印异常堆栈信息,便于排查问题
e.printStackTrace();
return Result.error(500, "服务器内部错误: " + e.getMessage());
}
/**
* 沿异常链查找指定类型的异常。
*
* @param throwable 原始异常
* @param type 目标异常类型
* @param <T> 异常泛型
* @return 找到的异常,未找到返回 null
*/
private <T extends Throwable> T findCause(Throwable throwable, Class<T> type) {
Throwable current = throwable;
while (current != null) {
if (type.isInstance(current)) {
return type.cast(current);
}
if (current.getCause() == current) {
break;
}
current = current.getCause();
}
return null;
}
}
@@ -0,0 +1,92 @@
package cn.rosercode.finance.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 前端单页应用路由回退控制器。
*
* <p>前后端一体化部署时,前端使用 History 路由模式,直接访问或刷新 {@code /dashboard} 等
* 子路径时服务器上并不存在对应文件,会被解析为 404。本控制器在 404 时判断:若请求路径不是
* 接口或文档等资源路径,且不带文件扩展名,则转发到前端入口 {@code /index.html},交由前端路由处理。
* 其余情况仍返回 JSON 错误信息,避免影响接口与 API 文档。</p>
*
* @date 2026-09-18 11:16:04
*/
@Controller
public class SpaErrorController implements ErrorController {
private final ObjectMapper objectMapper;
public SpaErrorController(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 处理容器转发的错误请求。
*
* @param request HTTP 请求
* @param response HTTP 响应
* @throws IOException 写出响应时可能抛出
* @throws ServletException 转发到前端入口时可能抛出
*/
@RequestMapping("/error")
public void handleError(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
int status = resolveStatus(request);
String path = resolvePath(request);
if (status == 404 && SpaRouteSupport.isSpaRoute(path)) {
// 转发到前端入口,交由前端路由处理(如刷新 /dashboard)
request.getRequestDispatcher("/index.html").forward(request, response);
return;
}
response.setStatus(status);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", status);
body.put("message", status == 404 ? "资源不存在" : "服务器内部错误");
body.put("data", null);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
/**
* 从请求属性中解析错误状态码。
*
* @param request HTTP 请求
* @return 状态码,缺失时返回 500
*/
private int resolveStatus(HttpServletRequest request) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (status == null) {
return 500;
}
try {
return Integer.parseInt(status.toString());
} catch (NumberFormatException e) {
return 500;
}
}
/**
* 从请求属性中解析原始请求路径。
*
* @param request HTTP 请求
* @return 请求路径
*/
private String resolvePath(HttpServletRequest request) {
Object uri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
return uri != null ? uri.toString() : "";
}
}
@@ -0,0 +1,36 @@
package cn.rosercode.finance.config;
/**
* SPA 路由判断工具。
*
* <p>前后端一体化部署时,需要区分「前端 History 路由」与「接口 / 静态资源 / API 文档」请求。
* 前者在服务器上无对应文件,应回退到前端入口;后者应正常返回 404 或由对应处理器处理。</p>
*
* @date 2026-09-18 11:22:02
*/
public final class SpaRouteSupport {
private SpaRouteSupport() {
}
/**
* 判断请求路径是否为需要回退到前端入口的 SPA 路由。
*
* @param path 请求路径
* @return 需要回退返回 true
*/
public static boolean isSpaRoute(String path) {
if (path == null || path.isEmpty()) {
return false;
}
// 接口、API 文档等路径不做回退
if (path.startsWith("/api") || path.startsWith("/v3/api-docs")
|| path.startsWith("/swagger-ui") || path.startsWith("/webjars")
|| path.startsWith("/doc.html") || path.startsWith("/favicon")) {
return false;
}
// 带扩展名的视为静态资源请求
String lastSegment = path.substring(path.lastIndexOf('/') + 1);
return !lastSegment.contains(".");
}
}
@@ -34,8 +34,9 @@ public class WebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**")
// 登录和注册接口不需要认证
.excludePathPatterns("/api/user/login", "/api/user/register");
// 登录、注册与状态查询接口不需要认证;数据库状态查询用于前端判断是否已配置
.excludePathPatterns("/api/user/login", "/api/user/register",
"/api/user/registration-status", "/api/database/status");
}
/**
@@ -0,0 +1,20 @@
package cn.rosercode.finance.config.app;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
/**
* 应用级运行时配置。
*
* <p>目前包含注册开关,持久化在本地文件 {@code config/app.json}。由于注册入口需要在
* 未登录状态下即可判断,且不希望依赖数据库,因此采用本地文件存储。</p>
*
* @date 2026-09-18 12:09:20
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AppConfig {
/** 是否开放注册:true 开放,false 关闭(默认关闭) */
private boolean registrationEnabled = false;
}
@@ -0,0 +1,81 @@
package cn.rosercode.finance.config.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 应用级运行时配置的本地文件存储。
*
* <p>配置以 JSON 形式保存在工作目录下的 {@code config/app.json}。文件不存在或解析失败时
* 返回默认配置(注册关闭),保证在首次部署时注册功能默认禁用。</p>
*
* @date 2026-09-18 12:09:22
*/
@Component
public class AppConfigStore {
private static final Logger log = LoggerFactory.getLogger(AppConfigStore.class);
/** 配置目录 */
private static final String CONFIG_DIR = "config";
/** 配置文件名 */
private static final String CONFIG_FILE = "app.json";
private final ObjectMapper objectMapper;
public AppConfigStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 获取配置文件路径。
*
* @return 配置文件路径
*/
public Path getConfigFile() {
return Paths.get(CONFIG_DIR, CONFIG_FILE);
}
/**
* 读取应用配置,文件不存在或解析失败时返回默认配置。
*
* @return 应用配置
*/
public AppConfig load() {
Path file = getConfigFile();
if (!Files.exists(file)) {
return new AppConfig();
}
try {
AppConfig config = objectMapper.readValue(file.toFile(), AppConfig.class);
return config != null ? config : new AppConfig();
} catch (IOException e) {
log.error("读取应用配置文件 {} 失败,使用默认配置:{}", file, e.getMessage());
return new AppConfig();
}
}
/**
* 保存应用配置,自动创建父目录。
*
* @param config 应用配置
*/
public void save(AppConfig config) {
Path file = getConfigFile();
try {
Files.createDirectories(file.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
log.info("应用配置已保存到 {}", file.toAbsolutePath());
} catch (IOException e) {
throw new IllegalStateException("保存应用配置失败:" + e.getMessage(), e);
}
}
}
@@ -0,0 +1,71 @@
package cn.rosercode.finance.config.datasource;
/**
* 动态数据源上下文,保存当前生效的数据源标识与类型。
*
* <p>由于本系统为单实例个人应用,数据库为全局单例配置,因此使用静态 {@code volatile} 字段
* 而非 ThreadLocal 保存当前数据源。动态数据源在每次获取连接时都会读取这里的标识,
* 从而实现运行时热切换。</p>
*
* @date 2026-09-18 11:00:17
*/
public final class DataSourceContextHolder {
/** 未配置数据库时使用的占位数据源标识 */
public static final String UNCONFIGURED_KEY = "UNCONFIGURED";
/** 当前数据源标识 */
private static volatile String key = UNCONFIGURED_KEY;
/** 当前数据源类型,未配置时为 null */
private static volatile DataSourceType type;
private DataSourceContextHolder() {
}
/**
* 获取当前数据源标识。
*
* @return 数据源标识
*/
public static String getKey() {
return key;
}
/**
* 获取当前数据源类型。
*
* @return 数据源类型,未配置时返回 null
*/
public static DataSourceType getType() {
return type;
}
/**
* 判断是否已完成数据库配置。
*
* @return 已配置返回 true
*/
public static boolean isConfigured() {
return type != null;
}
/**
* 设置当前生效的数据源。
*
* @param dataSourceType 数据源类型
* @param dataSourceKey 数据源标识
*/
public static void set(DataSourceType dataSourceType, String dataSourceKey) {
key = dataSourceKey;
type = dataSourceType;
}
/**
* 重置为未配置状态。
*/
public static void reset() {
key = UNCONFIGURED_KEY;
type = null;
}
}
@@ -0,0 +1,46 @@
package cn.rosercode.finance.config.datasource;
/**
* 数据库类型枚举,标识当前支持的数据库种类。
*
* <p>每种类型携带对应的 JDBC 驱动类名与显示名称,供动态数据源构建连接时使用。</p>
*
* @date 2026-09-18 11:00:06
*/
public enum DataSourceType {
/** MySQL 数据库 */
MYSQL("com.mysql.cj.jdbc.Driver", "MySQL"),
/** SQLite 本地文件数据库 */
SQLITE("org.sqlite.JDBC", "SQLite");
/** JDBC 驱动类名 */
private final String driverClassName;
/** 显示名称 */
private final String displayName;
DataSourceType(String driverClassName, String displayName) {
this.driverClassName = driverClassName;
this.displayName = displayName;
}
/**
* 获取 JDBC 驱动类名。
*
* @return 驱动类名
*/
public String getDriverClassName() {
return driverClassName;
}
/**
* 获取显示名称。
*
* @return 显示名称
*/
public String getDisplayName() {
return displayName;
}
}
@@ -0,0 +1,34 @@
package cn.rosercode.finance.config.datasource;
import cn.rosercode.finance.service.DatabaseService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 数据库启动引导器。
*
* <p>在 Spring 上下文准备就绪后触发一次数据库配置加载:优先使用本地保存的配置,
* 其次兼容环境变量。若均不存在,则保持未配置状态,等待用户通过设置页配置。</p>
*
* @date 2026-09-18 11:01:22
*/
@Component
public class DatabaseBootstrap implements ApplicationRunner {
private final DatabaseService databaseService;
public DatabaseBootstrap(DatabaseService databaseService) {
this.databaseService = databaseService;
}
/**
* 应用启动完成后执行数据库引导。
*
* @param args 启动参数
*/
@Override
public void run(ApplicationArguments args) {
databaseService.bootstrap();
}
}
@@ -0,0 +1,68 @@
package cn.rosercode.finance.config.datasource;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
/**
* 数据库连接配置,描述一个可用的 MySQL 或 SQLite 数据源。
*
* <p>该对象会以 JSON 形式持久化到本地文件 {@code config/database.json},
* 因为应用启动时可能尚未连接任何数据库,无法把配置存入数据库本身。</p>
*
* @date 2026-09-18 11:00:10
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DatabaseConfig {
/** 数据库类型 */
private DataSourceType type;
/** SQLite 数据库文件路径(仅 SQLite 使用) */
private String sqlitePath;
/** MySQL 主机地址(仅 MySQL 使用) */
private String host;
/** MySQL 端口(仅 MySQL 使用) */
private Integer port;
/** MySQL 数据库名(仅 MySQL 使用) */
private String database;
/** MySQL 用户名(仅 MySQL 使用) */
private String username;
/** MySQL 密码(仅 MySQL 使用,明文存储,与 AI 配置一致) */
private String password;
/**
* 根据配置构建 JDBC 连接地址。
*
* <p>MySQL 地址附带常用参数:UTF-8 编码、关闭 SSL、上海时区、允许公钥检索,
* 并开启 {@code createDatabaseIfNotExist},便于首次连接自动建库。</p>
*
* @return JDBC URL
*/
public String buildJdbcUrl() {
if (type == DataSourceType.SQLITE) {
return "jdbc:sqlite:" + sqlitePath;
}
return String.format(
"jdbc:mysql://%s:%d/%s?useUnicode=true&characterEncoding=utf8&useSSL=false"
+ "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true",
host, port, database);
}
/**
* 生成用于展示的脱敏连接摘要,不包含密码。
*
* @return 连接摘要
*/
public String maskedSummary() {
if (type == DataSourceType.SQLITE) {
return sqlitePath;
}
return String.format("%s:%d/%s", host, port, database);
}
}
@@ -0,0 +1,106 @@
package cn.rosercode.finance.config.datasource;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
/**
* 数据库配置的本地文件存储。
*
* <p>配置以 JSON 形式保存在工作目录下的 {@code config/database.json}。之所以不使用数据库存储,
* 是因为应用需要在尚未连接任何数据库时就能读取配置并完成初始化,形成"先有配置、后连数据库"的顺序。</p>
*
* @date 2026-09-18 11:00:14
*/
@Component
public class DatabaseConfigStore {
private static final Logger log = LoggerFactory.getLogger(DatabaseConfigStore.class);
/** 配置文件相对工作目录的位置 */
private static final String CONFIG_DIR = "config";
/** 配置文件名称 */
private static final String CONFIG_FILE = "database.json";
private final ObjectMapper objectMapper;
public DatabaseConfigStore(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 获取配置文件路径。
*
* @return 配置文件路径
*/
public Path getConfigFile() {
return Paths.get(CONFIG_DIR, CONFIG_FILE);
}
/**
* 判断本地是否已存在配置文件。
*
* @return 存在返回 true
*/
public boolean exists() {
return Files.exists(getConfigFile());
}
/**
* 读取本地配置,文件不存在或解析失败时返回空。
*
* @return 数据库配置
*/
public Optional<DatabaseConfig> load() {
Path file = getConfigFile();
if (!Files.exists(file)) {
return Optional.empty();
}
try {
DatabaseConfig config = objectMapper.readValue(file.toFile(), DatabaseConfig.class);
if (config == null || config.getType() == null) {
log.warn("数据库配置文件 {} 内容不完整,已忽略", file);
return Optional.empty();
}
return Optional.of(config);
} catch (IOException e) {
log.error("读取数据库配置文件 {} 失败:{}", file, e.getMessage());
return Optional.empty();
}
}
/**
* 将配置写入本地文件,自动创建父目录。
*
* @param config 数据库配置
*/
public void save(DatabaseConfig config) {
Path file = getConfigFile();
try {
Files.createDirectories(file.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
log.info("数据库配置已保存到 {}", file.toAbsolutePath());
} catch (IOException e) {
throw new IllegalStateException("保存数据库配置失败:" + e.getMessage(), e);
}
}
/**
* 删除本地配置文件。
*/
public void clear() {
try {
Files.deleteIfExists(getConfigFile());
} catch (IOException e) {
log.error("删除数据库配置文件失败:{}", e.getMessage());
}
}
}
@@ -0,0 +1,89 @@
package cn.rosercode.finance.config.datasource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 动态数据源,支持在运行时切换底层真实数据源。
*
* <p>继承 Spring 的 {@link AbstractRoutingDataSource},每次获取连接时根据
* {@link DataSourceContextHolder} 中的标识选择目标数据源。由于父类不暴露已注册的目标数据源,
* 本类自行维护一份映射,切换时同步到父类并触发 {@link #afterPropertiesSet()} 重建解析缓存,
* 同时关闭被替换的旧连接池。</p>
*
* @date 2026-09-18 11:00:28
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
private static final Logger log = LoggerFactory.getLogger(DynamicDataSource.class);
/** 自行维护的目标数据源映射,用于运行时增量注册与切换 */
private final Map<Object, Object> targetDataSources = new ConcurrentHashMap<>();
/**
* 构造动态数据源并注册初始占位数据源。
*
* @param placeholder 未配置数据库时使用的占位数据源
*/
public DynamicDataSource(DataSource placeholder) {
targetDataSources.put(DataSourceContextHolder.UNCONFIGURED_KEY, placeholder);
setTargetDataSources(new HashMap<>(targetDataSources));
setDefaultTargetDataSource(placeholder);
afterPropertiesSet();
}
/**
* 返回当前请求应使用的数据源标识。
*
* @return 数据源标识
*/
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getKey();
}
/**
* 注册并切换到指定类型的数据源。
*
* <p>切换过程为:更新内部映射 -> 同步到父类并重建解析缓存 -> 更新上下文标识 -> 关闭旧数据源。
* 方法加锁以保证并发切换时的映射一致性。</p>
*
* @param type 数据源类型
* @param dataSource 新的数据源
*/
public synchronized void addAndSwitch(DataSourceType type, DataSource dataSource) {
String key = type.name();
DataSource previous = (DataSource) targetDataSources.put(key, dataSource);
setTargetDataSources(new HashMap<>(targetDataSources));
// 重新解析目标数据源映射,使新数据源立即生效
afterPropertiesSet();
DataSourceContextHolder.set(type, key);
if (previous != null && previous != dataSource) {
closeQuietly(previous);
}
log.info("已切换到 {} 数据源", type.getDisplayName());
}
/**
* 安静地关闭数据源,忽略关闭过程中的异常。
*
* @param dataSource 待关闭的数据源
*/
private void closeQuietly(DataSource dataSource) {
if (dataSource instanceof AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception e) {
log.warn("关闭旧数据源失败:{}", e.getMessage());
}
}
}
}
@@ -0,0 +1,29 @@
package cn.rosercode.finance.config.datasource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* 动态数据源配置类。
*
* <p>向 Spring 容器注册唯一的 {@link DynamicDataSource} 作为主数据源。初始时仅注册一个
* 未配置占位数据源,保证应用在没有数据库配置的情况下也能启动;真正的数据库连接在
* 运行时配置完成后动态注册进来。</p>
*
* @date 2026-09-18 11:00:31
*/
@Configuration
public class DynamicDataSourceConfig {
/**
* 创建动态数据源,初始指向未配置占位数据源。
*
* @return 动态数据源实例
*/
@Bean
@Primary
public DynamicDataSource dynamicDataSource() {
return new DynamicDataSource(new UnconfiguredDataSource());
}
}
@@ -0,0 +1,51 @@
package cn.rosercode.finance.config.datasource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
/**
* 数据库表结构初始化器。
*
* <p>根据数据库类型选择对应的建表脚本并执行。所有建表语句均为幂等(IF NOT EXISTS),
* 因此重复执行不会破坏已有数据。</p>
*
* @date 2026-09-18 11:00:46
*/
@Component
public class SchemaInitializer {
private static final Logger log = LoggerFactory.getLogger(SchemaInitializer.class);
/** MySQL 建表脚本 */
private static final String MYSQL_SCRIPT = "sql/init.sql";
/** SQLite 建表脚本 */
private static final String SQLITE_SCRIPT = "sql/schema-sqlite.sql";
/**
* 执行建表脚本。
*
* @param dataSource 目标数据源
* @param type 数据库类型
*/
public void initialize(DataSource dataSource, DataSourceType type) {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.setContinueOnError(false);
if (type == DataSourceType.SQLITE) {
// SQLite 脚本以 ;; 分隔,避免 CREATE TRIGGER 体内的分号被误拆分
populator.setSeparator(";;");
}
String script = type == DataSourceType.MYSQL ? MYSQL_SCRIPT : SQLITE_SCRIPT;
populator.addScript(new ClassPathResource(script));
log.info("开始执行 {} 建表脚本:{}", type.getDisplayName(), script);
populator.execute(dataSource);
log.info("{} 建表脚本执行完成", type.getDisplayName());
}
}
@@ -0,0 +1,66 @@
package cn.rosercode.finance.config.datasource;
import cn.rosercode.finance.common.DatabaseNotConfiguredException;
import javax.sql.DataSource;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.logging.Logger;
/**
* 未配置数据库时使用的占位数据源。
*
* <p>该数据源不连接任何真实数据库,任何获取连接的尝试都会抛出
* {@link DatabaseNotConfiguredException},由全局异常处理器转换为"数据库未配置"的业务响应,
* 从而保证应用在尚未配置数据库时也能正常启动。</p>
*
* @date 2026-09-18 11:00:20
*/
public class UnconfiguredDataSource implements DataSource {
@Override
public Connection getConnection() {
throw new DatabaseNotConfiguredException();
}
@Override
public Connection getConnection(String username, String password) {
throw new DatabaseNotConfiguredException();
}
@Override
public PrintWriter getLogWriter() {
return null;
}
@Override
public void setLogWriter(PrintWriter out) {
// 占位数据源无需日志输出
}
@Override
public void setLoginTimeout(int seconds) {
// 占位数据源无需登录超时设置
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public Logger getParentLogger() {
return Logger.getLogger(Logger.GLOBAL_LOGGER_NAME);
}
@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
throw new SQLException("占位数据源不支持 unwrap");
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return false;
}
}
@@ -0,0 +1,64 @@
package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.DatabaseConfigRequest;
import cn.rosercode.finance.service.DatabaseService;
import cn.rosercode.finance.vo.DatabaseStatusVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 数据库配置控制器。
*
* <p>提供数据库状态查询、连接测试与配置保存接口。数据库未配置时这些接口允许匿名访问,
* 否则用户无法在无法登录的情况下完成初始化;一旦配置完成,则需携带 JWT 才能修改,
* 具体由 {@code JwtInterceptor} 动态控制。</p>
*
* @date 2026-09-18 11:01:23
*/
@RestController
@RequestMapping("/api/database")
public class DatabaseController {
private final DatabaseService databaseService;
public DatabaseController(DatabaseService databaseService) {
this.databaseService = databaseService;
}
/**
* 查询当前数据库配置状态。
*
* @return 数据库状态
*/
@GetMapping("/status")
public Result<DatabaseStatusVO> status() {
return Result.success(databaseService.getStatus());
}
/**
* 测试数据库连接。
*
* @param request 数据库配置请求
* @return 无数据成功响应
*/
@PostMapping("/test")
public Result<Void> test(@RequestBody DatabaseConfigRequest request) {
databaseService.testConnection(request);
return Result.success();
}
/**
* 保存数据库配置并切换到新的数据源。
*
* @param request 数据库配置请求
* @return 切换后的数据库状态
*/
@PostMapping("/config")
public Result<DatabaseStatusVO> save(@RequestBody DatabaseConfigRequest request) {
return Result.success(databaseService.save(request));
}
}
@@ -3,8 +3,11 @@ package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.dto.RegistrationUpdateRequest;
import cn.rosercode.finance.service.RegistrationService;
import cn.rosercode.finance.service.UserService;
import cn.rosercode.finance.vo.LoginVO;
import cn.rosercode.finance.vo.RegistrationStatusVO;
import cn.rosercode.finance.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,6 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -27,9 +31,11 @@ import org.springframework.web.bind.annotation.RestController;
public class UserController {
private final UserService userService;
private final RegistrationService registrationService;
public UserController(UserService userService) {
public UserController(UserService userService, RegistrationService registrationService) {
this.userService = userService;
this.registrationService = registrationService;
}
/**
@@ -57,6 +63,30 @@ public class UserController {
return Result.success();
}
/**
* 查询注册状态(无需登录)
*
* @return 注册状态,包含开关、是否首个用户以及当前是否允许注册
*/
@GetMapping("/registration-status")
@Operation(summary = "查询注册状态", description = "供登录页判断是否显示注册入口")
public Result<RegistrationStatusVO> registrationStatus() {
return Result.success(registrationService.getStatus());
}
/**
* 更新注册开关(需登录)
*
* @param request 注册开关更新请求
* @return 更新后的注册状态
*/
@PutMapping("/registration")
@Operation(summary = "更新注册开关", description = "开启或关闭注册功能")
public Result<RegistrationStatusVO> updateRegistration(@RequestBody RegistrationUpdateRequest request) {
registrationService.setEnabled(Boolean.TRUE.equals(request.getEnabled()));
return Result.success(registrationService.getStatus());
}
/**
* 获取当前用户信息
*
@@ -1,5 +1,6 @@
package cn.rosercode.finance.dto;
import cn.rosercode.finance.vo.AiNewTagVO;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
@@ -33,7 +34,16 @@ public class AiApplyRequest {
/** 建议分类ID,为 null 表示不修改分类 */
private Long categoryId;
/** 建议新建的分类名称,categoryId 为空时按名称查找或创建 */
private String newCategoryName;
/** 建议新建的分类图标(Emoji) */
private String newCategoryIcon;
/** 建议追加的标签ID列表,可为空表示不追加标签 */
private List<Long> tagIds;
/** 建议新建的标签列表,按名称查找或创建后追加 */
private List<AiNewTagVO> newTags;
}
}
@@ -0,0 +1,34 @@
package cn.rosercode.finance.dto;
import cn.rosercode.finance.config.datasource.DataSourceType;
import lombok.Data;
/**
* 数据库配置请求对象,用于提交 MySQL 或 SQLite 的连接信息。
*
* @date 2026-09-18 11:00:59
*/
@Data
public class DatabaseConfigRequest {
/** 数据库类型:MYSQL 或 SQLITE */
private DataSourceType type;
/** SQLite 数据库文件路径(仅 SQLite 使用) */
private String sqlitePath;
/** MySQL 主机地址(仅 MySQL 使用) */
private String host;
/** MySQL 端口(仅 MySQL 使用) */
private Integer port;
/** MySQL 数据库名(仅 MySQL 使用) */
private String database;
/** MySQL 用户名(仅 MySQL 使用) */
private String username;
/** MySQL 密码(仅 MySQL 使用);留空表示沿用已保存的密码 */
private String password;
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 注册开关更新请求对象。
*
* @date 2026-09-18 12:09:24
*/
@Data
public class RegistrationUpdateRequest {
/** 目标开关状态:true 开启注册,false 关闭注册 */
private Boolean enabled;
}
@@ -1,5 +1,6 @@
package cn.rosercode.finance.interceptor;
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
import cn.rosercode.finance.util.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
@@ -49,6 +50,13 @@ public class JwtInterceptor implements HandlerInterceptor {
return true;
}
// 数据库未配置时放行数据库配置接口,否则用户无法登录、陷入死锁;
// 配置完成后这些接口重新纳入 JWT 认证保护
String uri = request.getRequestURI();
if (uri.startsWith("/api/database/") && !DataSourceContextHolder.isConfigured()) {
return true;
}
// 从 Authorization 请求头中提取 Token
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
@@ -0,0 +1,39 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.DatabaseConfigRequest;
import cn.rosercode.finance.vo.DatabaseStatusVO;
/**
* 数据库配置服务,负责连接测试、运行时切换与启动引导。
*
* @date 2026-09-18 11:01:02
*/
public interface DatabaseService {
/**
* 获取当前数据库配置状态。
*
* @return 数据库状态
*/
DatabaseStatusVO getStatus();
/**
* 测试数据库连接是否可用,不会改变当前生效的数据源。
*
* @param request 数据库配置请求
*/
void testConnection(DatabaseConfigRequest request);
/**
* 保存配置并切换到新的数据源,同时自动初始化表结构。
*
* @param request 数据库配置请求
* @return 切换后的数据库状态
*/
DatabaseStatusVO save(DatabaseConfigRequest request);
/**
* 应用启动时引导数据库:优先读取本地配置,其次读取环境变量。
*/
void bootstrap();
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.vo.RegistrationStatusVO;
/**
* 注册管理服务,负责注册开关的读取、更新与注册放行判断。
*
* @date 2026-09-18 12:09:25
*/
public interface RegistrationService {
/**
* 获取当前注册状态。
*
* @return 注册状态
*/
RegistrationStatusVO getStatus();
/**
* 判断当前是否允许注册(开关开启,或系统尚无任何用户)。
*
* @return 允许注册返回 true
*/
boolean isRegistrationAllowed();
/**
* 设置注册开关并持久化。
*
* @param enabled 是否开放注册
*/
void setEnabled(boolean enabled);
}
@@ -20,6 +20,7 @@ import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiNewTagVO;
import cn.rosercode.finance.vo.AiSuggestionVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -30,11 +31,13 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -54,14 +57,39 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/** 原始账单数据注入提示词时的最大长度 */
private static final int RAW_DATA_LIMIT = 300;
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
/** 分类/标签名称最大长度 */
private static final int NAME_MAX_LENGTH = 20;
/** 分类图标最大长度(按 UTF-16 字符数限制) */
private static final int ICON_MAX_LENGTH = 16;
/** 默认标签颜色,AI 未返回合法颜色时使用 */
private static final String DEFAULT_TAG_COLOR = "#2080f0";
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
/** 严格模式系统提示词,仅允许使用现有分类与标签,供编辑弹窗单条建议使用 */
private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
+ "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
+ "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
+ "4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
/** 增强模式系统提示词,允许在现有候选都不合适时提出新建分类与标签 */
private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、金额、备注等信息,"
+ "为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。"
+ "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
+ "2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;"
+ "3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),"
+ "或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);"
+ "4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;"
+ "5. 标签可多选,也可为空数组;6. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID或null,"
+ "\"newCategory\":{\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🐾\"}或null,"
+ "\"tagIds\":[标签ID],\"newTags\":[{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],\"reason\":\"简要中文理由\"}]}";
private final AiConfigService aiConfigService;
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
@@ -128,11 +156,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
throw new BusinessException("未找到可分析的交易记录");
}
// 候选列表允许为空,此时由模型提出新建分类与标签
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
if (categories.isEmpty()) {
throw new BusinessException("请先创建分类后再使用 AI 建议");
}
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
@@ -216,8 +242,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, userPrompt);
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
STRICT_SYSTEM_PROMPT, userPrompt);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
System.currentTimeMillis() - start, e.getMessage());
@@ -324,12 +350,12 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
*/
private String buildSystemPrompt(String scope) {
if ("CATEGORY".equals(scope)) {
return SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组。";
return NEW_SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。";
}
if ("TAG".equals(scope)) {
return SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null。";
return NEW_SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。";
}
return SYSTEM_PROMPT;
return NEW_SYSTEM_PROMPT;
}
/**
@@ -419,6 +445,11 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
Map<Long, Tag> tagMap = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId))
.stream().collect(Collectors.toMap(Tag::getId, Function.identity()));
// 名称映射同时作为本次新建的缓存,避免同批重复创建
Map<String, Category> categoryNameMap = categoryMap.values().stream()
.collect(Collectors.toMap(c -> c.getType() + "|" + c.getName(), Function.identity(), (a, b) -> a));
Map<String, Tag> tagNameMap = tagMap.values().stream()
.collect(Collectors.toMap(Tag::getName, Function.identity(), (a, b) -> a));
int applied = 0;
for (AiApplyRequest.Item item : items) {
@@ -428,25 +459,43 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
}
boolean changed = false;
// 分类:仅接受类型匹配且确属当前用户的分类
if (item.getCategoryId() != null) {
Category category = categoryMap.get(item.getCategoryId());
if (category != null
&& category.getType().equals(transaction.getType())
&& !category.getId().equals(transaction.getCategoryId())) {
transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getId, transaction.getId())
.eq(Transaction::getUserId, userId)
.set(Transaction::getCategoryId, category.getId()));
changed = true;
}
// 分类:优先使用现有分类,否则按 AI 建议的名称查找或创建
Category category = item.getCategoryId() == null ? null : categoryMap.get(item.getCategoryId());
if (category == null && !isBlank(item.getNewCategoryName())) {
category = resolveCategory(userId, transaction.getType(), item.getNewCategoryName().trim(),
item.getNewCategoryIcon(), categoryNameMap);
}
if (category != null
&& category.getType().equals(transaction.getType())
&& !category.getId().equals(transaction.getCategoryId())) {
transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getId, transaction.getId())
.eq(Transaction::getUserId, userId)
.set(Transaction::getCategoryId, category.getId()));
changed = true;
}
// 标签:追加尚未关联的有效标签
List<Long> validTagIds = item.getTagIds() == null ? List.of() : item.getTagIds().stream()
.filter(tagMap::containsKey)
.distinct()
.collect(Collectors.toList());
// 标签:现有标签ID + AI 建议的新标签,追加尚未关联的有效标签
List<Long> validTagIds = new ArrayList<>();
if (item.getTagIds() != null) {
item.getTagIds().stream().filter(tagMap::containsKey).distinct().forEach(validTagIds::add);
}
if (item.getNewTags() != null) {
for (AiNewTagVO newTag : item.getNewTags()) {
if (newTag == null || isBlank(newTag.getName())
|| newTag.getName().trim().length() > NAME_MAX_LENGTH) {
continue;
}
String color = newTag.getColor();
if (color == null || !COLOR_PATTERN.matcher(color).matches()) {
color = DEFAULT_TAG_COLOR;
}
Tag tag = resolveTag(userId, newTag.getName().trim(), color.toLowerCase(), tagNameMap);
if (!validTagIds.contains(tag.getId())) {
validTagIds.add(tag.getId());
}
}
}
if (!validTagIds.isEmpty()) {
Set<Long> existingTagIds = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, transaction.getId()))
@@ -470,6 +519,58 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
return applied;
}
/**
* 按「类型 + 名称」查找分类,不存在时创建并写入缓存。
*
* @param userId 用户ID
* @param type 分类类型,取记录自身类型
* @param name 分类名称
* @param icon 分类图标
* @param categoryNameMap 类型与名称到分类的映射,同时作为新建缓存
* @return 已存在或新建的分类
*/
private Category resolveCategory(Long userId, String type, String name, String icon,
Map<String, Category> categoryNameMap) {
String key = type + "|" + name;
Category existing = categoryNameMap.get(key);
if (existing != null) {
return existing;
}
Category category = new Category();
category.setUserId(userId);
category.setName(name);
category.setType(type);
category.setIcon(icon);
category.setSortOrder(0);
categoryMapper.insert(category);
categoryNameMap.put(key, category);
return category;
}
/**
* 按名称查找标签,不存在时创建并写入缓存。
*
* @param userId 用户ID
* @param name 标签名称
* @param color 标签颜色
* @param tagNameMap 名称到标签的映射,同时作为新建缓存
* @return 已存在或新建的标签
*/
private Tag resolveTag(Long userId, String name, String color, Map<String, Tag> tagNameMap) {
Tag existing = tagNameMap.get(name);
if (existing != null) {
return existing;
}
Tag tag = new Tag();
tag.setUserId(userId);
tag.setName(name);
tag.setColor(color);
tag.setSortOrder(0);
tagMapper.insert(tag);
tagNameMap.put(name, tag);
return tag;
}
/**
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
*
@@ -546,6 +647,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
.collect(Collectors.toMap(Category::getId, Function.identity()));
Map<Long, Tag> tagMap = tags.stream()
.collect(Collectors.toMap(Tag::getId, Function.identity()));
// 名称到标签的映射,用于把 AI 提出的新标签与现有标签同名时直接复用
Map<String, Tag> tagNameMap = tags.stream()
.collect(Collectors.toMap(Tag::getName, Function.identity(), (a, b) -> a));
List<AiSuggestionVO> result = new ArrayList<>();
for (JsonNode node : array) {
@@ -557,19 +661,22 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
AiSuggestionVO vo = new AiSuggestionVO();
vo.setTransactionId(id);
// 分类必须存在且与记录类型一致(仅标签建议时跳过)
// 分类:优先使用现有且类型一致的分类,否则接受模型提出的新分类
if (wantCategory) {
long categoryId = node.path("categoryId").asLong(0);
Category category = categoryMap.get(categoryId);
if (category != null && category.getType().equals(transaction.getType())) {
vo.setCategoryId(category.getId());
vo.setCategoryName(category.getName());
} else {
fillNewCategory(vo, node.path("newCategory"));
}
}
// 标签过滤为属于当前用户的标签(仅分类建议时跳过)
// 标签:现有标签按ID校验,新标签按名称去重、同名时复用现有标签
List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>();
List<AiNewTagVO> newTags = new ArrayList<>();
if (wantTag) {
JsonNode tagNode = node.path("tagIds");
if (tagNode.isArray()) {
@@ -581,19 +688,84 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
}
}
}
collectNewTags(node.path("newTags"), tagNameMap, tagIds, tagNames, newTags);
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
vo.setNewTags(newTags);
vo.setReason(node.path("reason").asText(""));
// 至少存在一项有效建议才返回
if (vo.getCategoryId() != null || !tagIds.isEmpty()) {
// 至少存在一项有效建议(现有或新建)才返回
if (vo.getCategoryId() != null || !isBlank(vo.getNewCategoryName())
|| !tagIds.isEmpty() || !newTags.isEmpty()) {
result.add(vo);
}
}
return result;
}
/**
* 从模型返回的 newCategory 节点填充新分类建议,名称非法时忽略。
*
* @param vo 建议对象
* @param node 模型返回的 newCategory 节点
*/
private void fillNewCategory(AiSuggestionVO vo, JsonNode node) {
if (!node.isObject()) {
return;
}
String name = node.path("name").asText("").trim();
if (isBlank(name) || name.length() > NAME_MAX_LENGTH) {
return;
}
String icon = node.path("icon").asText("").trim();
vo.setNewCategoryName(name);
vo.setNewCategoryIcon(icon.length() <= ICON_MAX_LENGTH ? icon : "");
}
/**
* 收集模型提出的新标签,按名称去重;与现有标签同名时转为现有标签ID。
*
* @param node 模型返回的 newTags 节点
* @param tagNameMap 现有标签名称到标签的映射
* @param tagIds 现有标签ID收集列表,同名标签会追加到这里
* @param tagNames 现有标签名称收集列表
* @param newTags 新标签收集列表
*/
private void collectNewTags(JsonNode node,
Map<String, Tag> tagNameMap,
List<Long> tagIds,
List<String> tagNames,
List<AiNewTagVO> newTags) {
if (!node.isArray()) {
return;
}
Set<String> seen = new LinkedHashSet<>();
for (JsonNode item : node) {
String name = item.path("name").asText("").trim();
if (isBlank(name) || name.length() > NAME_MAX_LENGTH || !seen.add(name)) {
continue;
}
// 与现有标签同名时直接复用,避免重复创建
Tag existing = tagNameMap.get(name);
if (existing != null) {
if (!tagIds.contains(existing.getId())) {
tagIds.add(existing.getId());
tagNames.add(existing.getName());
}
continue;
}
String color = item.path("color").asText("").trim();
if (!COLOR_PATTERN.matcher(color).matches()) {
color = DEFAULT_TAG_COLOR;
}
AiNewTagVO newTag = new AiNewTagVO();
newTag.setName(name);
newTag.setColor(color.toLowerCase());
newTags.add(newTag);
}
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
@@ -0,0 +1,382 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
import cn.rosercode.finance.config.datasource.DataSourceType;
import cn.rosercode.finance.config.datasource.DatabaseConfig;
import cn.rosercode.finance.config.datasource.DatabaseConfigStore;
import cn.rosercode.finance.config.datasource.DynamicDataSource;
import cn.rosercode.finance.config.datasource.SchemaInitializer;
import cn.rosercode.finance.dto.DatabaseConfigRequest;
import cn.rosercode.finance.service.DatabaseService;
import cn.rosercode.finance.vo.DatabaseStatusVO;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 数据库配置服务实现类。
*
* <p>负责连接测试、数据源创建与运行时切换、建表初始化,以及启动时的配置引导。
* 配置优先从本地文件 {@code config/database.json} 读取,其次兼容从环境变量
* {@code DB_URL/DB_USERNAME/DB_PASSWORD} 读取,以保证旧部署方式仍可平滑运行。</p>
*
* @date 2026-09-18 11:01:18
*/
@Service
public class DatabaseServiceImpl implements DatabaseService {
private static final Logger log = LoggerFactory.getLogger(DatabaseServiceImpl.class);
/** 从 MySQL JDBC URL 中解析主机、端口与库名 */
private static final Pattern MYSQL_URL_PATTERN =
Pattern.compile("jdbc:mysql://([^:/]+):(\\d+)/([^?]+)");
private final DynamicDataSource dynamicDataSource;
private final DatabaseConfigStore configStore;
private final SchemaInitializer schemaInitializer;
/** 当前生效的数据库配置,未配置时为 null */
private volatile DatabaseConfig currentConfig;
public DatabaseServiceImpl(DynamicDataSource dynamicDataSource,
DatabaseConfigStore configStore,
SchemaInitializer schemaInitializer) {
this.dynamicDataSource = dynamicDataSource;
this.configStore = configStore;
this.schemaInitializer = schemaInitializer;
}
/**
* {@inheritDoc}
*/
@Override
public DatabaseStatusVO getStatus() {
return buildStatus(currentConfig);
}
/**
* {@inheritDoc}
*
* 使用临时连接池验证连通性,验证完成后立即关闭,不影响当前数据源。
*/
@Override
public void testConnection(DatabaseConfigRequest request) {
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
try (HikariDataSource dataSource = createDataSource(config)) {
assertConnectable(dataSource);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("数据库连接失败:" + rootMessage(e));
}
}
/**
* {@inheritDoc}
*
* 执行顺序:创建并测试新数据源 -> 建表 -> 持久化配置 -> 切换数据源。
* 任一步骤失败都会关闭新数据源并保持原有配置不变。
*/
@Override
public synchronized DatabaseStatusVO save(DatabaseConfigRequest request) {
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
HikariDataSource newDataSource = createDataSource(config);
boolean switched = false;
try {
assertConnectable(newDataSource);
schemaInitializer.initialize(newDataSource, config.getType());
// 先落盘再切换,避免切换成功但配置未保存导致重启后状态不一致
configStore.save(config);
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
switched = true;
currentConfig = config;
log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
return buildStatus(config);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("数据库配置失败:" + rootMessage(e));
} finally {
if (!switched) {
newDataSource.close();
}
}
}
/**
* {@inheritDoc}
*
* 优先读取本地配置文件;若不存在,则尝试从环境变量构建配置并保存到本地。
*/
@Override
public void bootstrap() {
Optional<DatabaseConfig> stored = configStore.load();
if (stored.isPresent()) {
applyStoredConfig(stored.get());
return;
}
DatabaseConfig fromEnv = fromEnvironment();
if (fromEnv == null) {
log.warn("未检测到数据库配置,服务以未配置状态启动,请通过数据库设置页完成配置");
return;
}
try {
applyConfig(fromEnv);
configStore.save(fromEnv);
log.info("已从环境变量加载数据库配置并保存到本地配置文件");
} catch (Exception e) {
log.error("从环境变量加载数据库配置失败:{}", rootMessage(e));
}
}
/**
* 应用本地文件中的配置,失败时回退到未配置状态。
*
* @param config 本地保存的数据库配置
*/
private void applyStoredConfig(DatabaseConfig config) {
try {
applyConfig(config);
log.info("已从本地配置加载 {} 数据源:{}", config.getType().getDisplayName(), config.maskedSummary());
} catch (Exception e) {
log.error("加载本地数据库配置失败:{},服务将以未配置状态运行", rootMessage(e));
DataSourceContextHolder.reset();
}
}
/**
* 创建数据源、测试连通性、初始化表结构并切换。
*
* @param config 数据库配置
*/
private void applyConfig(DatabaseConfig config) {
validate(config);
normalizeSqlitePath(config);
HikariDataSource dataSource = createDataSource(config);
boolean switched = false;
try {
assertConnectable(dataSource);
schemaInitializer.initialize(dataSource, config.getType());
dynamicDataSource.addAndSwitch(config.getType(), dataSource);
switched = true;
currentConfig = config;
} finally {
if (!switched) {
dataSource.close();
}
}
}
/**
* 根据配置创建 Hikari 连接池。
*
* <p>SQLite 为单文件数据库,写操作会加文件锁,因此连接池大小固定为 1,避免并发写冲突。</p>
*
* @param config 数据库配置
* @return 连接池数据源
*/
private HikariDataSource createDataSource(DatabaseConfig config) {
HikariConfig hikari = new HikariConfig();
hikari.setPoolName("finance-" + config.getType().name().toLowerCase());
hikari.setDriverClassName(config.getType().getDriverClassName());
hikari.setJdbcUrl(config.buildJdbcUrl());
hikari.setConnectionTimeout(10_000);
if (config.getType() == DataSourceType.SQLITE) {
hikari.setMaximumPoolSize(1);
hikari.setMinimumIdle(1);
} else {
hikari.setUsername(config.getUsername());
hikari.setPassword(config.getPassword());
hikari.setMaximumPoolSize(10);
hikari.setMinimumIdle(2);
}
return new HikariDataSource(hikari);
}
/**
* 校验数据源是否可连接。
*
* @param dataSource 数据源
*/
private void assertConnectable(DataSource dataSource) {
try (Connection connection = dataSource.getConnection()) {
if (!connection.isValid(5)) {
throw new BusinessException("数据库连接无效");
}
} catch (SQLException e) {
throw new BusinessException("数据库连接失败:" + e.getMessage());
}
}
/**
* 将 SQLite 文件路径规范化为绝对路径,并确保父目录存在。
*
* @param config 数据库配置
*/
private void normalizeSqlitePath(DatabaseConfig config) {
if (config.getType() != DataSourceType.SQLITE) {
return;
}
Path path = Paths.get(config.getSqlitePath()).toAbsolutePath().normalize();
try {
if (path.getParent() != null) {
Files.createDirectories(path.getParent());
}
} catch (Exception e) {
throw new BusinessException("创建 SQLite 数据库目录失败:" + e.getMessage());
}
config.setSqlitePath(path.toString());
}
/**
* 校验配置完整性。
*
* @param config 数据库配置
*/
private void validate(DatabaseConfig config) {
if (config.getType() == null) {
throw new BusinessException("请选择数据库类型");
}
if (config.getType() == DataSourceType.SQLITE) {
if (!StringUtils.hasText(config.getSqlitePath())) {
throw new BusinessException("请填写 SQLite 数据库文件路径");
}
return;
}
if (!StringUtils.hasText(config.getHost())) {
throw new BusinessException("请填写数据库主机地址");
}
if (config.getPort() == null || config.getPort() < 1 || config.getPort() > 65535) {
throw new BusinessException("数据库端口不合法");
}
if (!StringUtils.hasText(config.getDatabase())) {
throw new BusinessException("请填写数据库名称");
}
if (!StringUtils.hasText(config.getUsername())) {
throw new BusinessException("请填写数据库用户名");
}
}
/**
* 将请求对象转换为内部配置对象,密码留空时沿用已保存的密码。
*
* @param request 请求对象
* @param base 当前已生效的配置,可为 null
* @return 数据库配置
*/
private DatabaseConfig toConfig(DatabaseConfigRequest request, DatabaseConfig base) {
DatabaseConfig config = new DatabaseConfig();
config.setType(request.getType());
config.setSqlitePath(request.getSqlitePath());
config.setHost(request.getHost());
config.setPort(request.getPort() != null ? request.getPort() : 3306);
config.setDatabase(request.getDatabase());
config.setUsername(request.getUsername());
config.setPassword(resolvePassword(request, base));
return config;
}
/**
* 解析 MySQL 密码:请求未提供密码时沿用已保存配置中的密码。
*
* @param request 请求对象
* @param base 当前已生效的配置
* @return 最终使用的密码
*/
private String resolvePassword(DatabaseConfigRequest request, DatabaseConfig base) {
if (request.getType() == DataSourceType.MYSQL
&& !StringUtils.hasText(request.getPassword())
&& base != null
&& base.getType() == DataSourceType.MYSQL) {
return base.getPassword();
}
return request.getPassword();
}
/**
* 从环境变量构建 MySQL 配置,兼容旧部署方式。
*
* @return 数据库配置,环境变量缺失或无法解析时返回 null
*/
private DatabaseConfig fromEnvironment() {
String url = System.getenv("DB_URL");
if (!StringUtils.hasText(url)) {
return null;
}
Matcher matcher = MYSQL_URL_PATTERN.matcher(url);
if (!matcher.find()) {
log.warn("环境变量 DB_URL 无法解析为 MySQL 连接地址,已忽略:{}", url);
return null;
}
DatabaseConfig config = new DatabaseConfig();
config.setType(DataSourceType.MYSQL);
config.setHost(matcher.group(1));
config.setPort(Integer.parseInt(matcher.group(2)));
config.setDatabase(matcher.group(3));
config.setUsername(System.getenv("DB_USERNAME"));
config.setPassword(System.getenv("DB_PASSWORD"));
return config;
}
/**
* 构建数据库状态视图对象。
*
* @param config 数据库配置,可为 null
* @return 状态视图对象
*/
private DatabaseStatusVO buildStatus(DatabaseConfig config) {
DatabaseStatusVO vo = new DatabaseStatusVO();
vo.setConfigFile(configStore.getConfigFile().toAbsolutePath().toString());
if (config == null) {
vo.setConfigured(false);
return vo;
}
vo.setConfigured(true);
vo.setType(config.getType());
vo.setTypeName(config.getType().getDisplayName());
vo.setSummary(config.maskedSummary());
vo.setHost(config.getHost());
vo.setPort(config.getPort());
vo.setDatabase(config.getDatabase());
vo.setUsername(config.getUsername());
vo.setSqlitePath(config.getSqlitePath());
return vo;
}
/**
* 获取异常链中最底层异常的消息。
*
* @param throwable 异常
* @return 根因消息
*/
private String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
}
}
@@ -0,0 +1,127 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 默认数据初始化器。
*
* <p>为新注册用户创建一套默认分类与标签。相比原先需要手动执行的 SQL 种子脚本,
* 使用 Java 代码插入可同时兼容 MySQL 与 SQLite 两种数据库。</p>
*
* @date 2026-09-18 11:02:15
*/
@Component
public class DefaultDataInitializer {
/** 默认支出分类(名称、图标、排序) */
private static final List<CategorySeed> DEFAULT_EXPENSE_CATEGORIES = List.of(
new CategorySeed("餐饮", "🍜", 10),
new CategorySeed("交通", "🚌", 20),
new CategorySeed("购物", "🛍️", 30),
new CategorySeed("居住", "🏠", 40),
new CategorySeed("通讯", "📱", 50),
new CategorySeed("娱乐", "🎮", 60),
new CategorySeed("医疗", "💊", 70),
new CategorySeed("教育", "📚", 80),
new CategorySeed("美容美发", "💇", 85),
new CategorySeed("人情往来", "🎁", 90),
new CategorySeed("其他支出", "📦", 100));
/** 默认收入分类(名称、图标、排序) */
private static final List<CategorySeed> DEFAULT_INCOME_CATEGORIES = List.of(
new CategorySeed("工资", "💰", 10),
new CategorySeed("奖金", "🧧", 20),
new CategorySeed("兼职", "💼", 30),
new CategorySeed("投资收益", "📈", 40),
new CategorySeed("退款", "↩️", 50),
new CategorySeed("其他收入", "📦", 60));
/** 默认标签(名称、颜色、排序) */
private static final List<TagSeed> DEFAULT_TAGS = List.of(
new TagSeed("报销", "#2080f0", 10),
new TagSeed("待报销", "#f0a020", 20),
new TagSeed("旅行", "#18a058", 30),
new TagSeed("家庭", "#8a2be2", 40),
new TagSeed("工作", "#d03050", 50),
new TagSeed("学习", "#0ea5e9", 60),
new TagSeed("医疗", "#e11d48", 70),
new TagSeed("大额消费", "#f97316", 80),
new TagSeed("订阅", "#7c3aed", 90),
new TagSeed("礼物", "#ec4899", 100),
new TagSeed("投资", "#16a34a", 110),
new TagSeed("宠物", "#a16207", 120),
new TagSeed("电影", "#14b8a6", 130));
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
public DefaultDataInitializer(CategoryMapper categoryMapper, TagMapper tagMapper) {
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
}
/**
* 为指定用户初始化默认分类与标签。
*
* @param userId 用户ID
*/
public void initializeForUser(Long userId) {
for (CategorySeed seed : DEFAULT_EXPENSE_CATEGORIES) {
insertCategory(userId, seed, "EXPENSE");
}
for (CategorySeed seed : DEFAULT_INCOME_CATEGORIES) {
insertCategory(userId, seed, "INCOME");
}
for (TagSeed seed : DEFAULT_TAGS) {
Tag tag = new Tag();
tag.setUserId(userId);
tag.setName(seed.name());
tag.setColor(seed.color());
tag.setSortOrder(seed.sortOrder());
tagMapper.insert(tag);
}
}
/**
* 插入一条分类记录。
*
* @param userId 用户ID
* @param seed 分类种子数据
* @param type 分类类型
*/
private void insertCategory(Long userId, CategorySeed seed, String type) {
Category category = new Category();
category.setUserId(userId);
category.setName(seed.name());
category.setType(type);
category.setIcon(seed.icon());
category.setSortOrder(seed.sortOrder());
categoryMapper.insert(category);
}
/**
* 分类种子数据。
*
* @param name 分类名称
* @param icon 分类图标
* @param sortOrder 排序序号
*/
private record CategorySeed(String name, String icon, int sortOrder) {
}
/**
* 标签种子数据。
*
* @param name 标签名称
* @param color 标签颜色
* @param sortOrder 排序序号
*/
private record TagSeed(String name, String color, int sortOrder) {
}
}
@@ -0,0 +1,76 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.config.app.AppConfig;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.mapper.UserMapper;
import cn.rosercode.finance.service.RegistrationService;
import cn.rosercode.finance.vo.RegistrationStatusVO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 注册管理服务实现类。
*
* <p>注册是否放行的规则为:注册开关开启,或系统尚无任何用户(允许创建首个用户)。
* 开关状态持久化在本地文件 {@code config/app.json}。</p>
*
* @date 2026-09-18 12:09:28
*/
@Service
public class RegistrationServiceImpl implements RegistrationService {
private static final Logger log = LoggerFactory.getLogger(RegistrationServiceImpl.class);
private final UserMapper userMapper;
private final AppConfigStore appConfigStore;
public RegistrationServiceImpl(UserMapper userMapper, AppConfigStore appConfigStore) {
this.userMapper = userMapper;
this.appConfigStore = appConfigStore;
}
/**
* {@inheritDoc}
*/
@Override
public RegistrationStatusVO getStatus() {
boolean enabled = appConfigStore.load().isRegistrationEnabled();
boolean firstUser = countUsers() == 0;
RegistrationStatusVO vo = new RegistrationStatusVO();
vo.setEnabled(enabled);
vo.setFirstUser(firstUser);
vo.setAllowed(enabled || firstUser);
return vo;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isRegistrationAllowed() {
return appConfigStore.load().isRegistrationEnabled() || countUsers() == 0;
}
/**
* {@inheritDoc}
*/
@Override
public void setEnabled(boolean enabled) {
AppConfig config = appConfigStore.load();
config.setRegistrationEnabled(enabled);
appConfigStore.save(config);
log.info("注册功能已{}", enabled ? "开启" : "关闭");
}
/**
* 统计系统用户数量。
*
* @return 用户数量
*/
private long countUsers() {
Long count = userMapper.selectCount(null);
return count == null ? 0L : count;
}
}
@@ -2,6 +2,8 @@ package cn.rosercode.finance.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.datasource.DataSourceContextHolder;
import cn.rosercode.finance.config.datasource.DataSourceType;
import cn.rosercode.finance.dto.StatisticsQuery;
import cn.rosercode.finance.dto.StatisticsRangeQuery;
import cn.rosercode.finance.entity.Category;
@@ -332,12 +334,29 @@ public class StatisticsServiceImpl implements StatisticsService {
}
/**
* 根据维度返回 SQL 分组表达式
* 根据维度返回 SQL 分组表达式。
*
* <p>MySQL 与 SQLite 的时间函数不同:MySQL 使用 {@code DATE_FORMAT/QUARTER},
* SQLite 使用 {@code strftime},因此需按当前数据源类型选择对应表达式。</p>
*
* @param dimension 统计维度
* @return 分组表达式
*/
private String groupExpression(StatisticsDimension dimension) {
if (DataSourceContextHolder.getType() == DataSourceType.SQLITE) {
return switch (dimension) {
case DAY -> "strftime('%Y-%m-%d', transaction_time)";
// SQLite 无 ISO 周函数,使用 %W(周一为一周起点)近似
case WEEK -> "strftime('%Y-W%W', transaction_time)";
case MONTH -> "strftime('%Y-%m', transaction_time)";
case QUARTER -> "strftime('%Y', transaction_time) || '-Q' || "
+ "((CAST(strftime('%m', transaction_time) AS INTEGER) + 2) / 3)";
case YEAR -> "strftime('%Y', transaction_time)";
case PAYMENT_METHOD -> "payment_method";
case CATEGORY -> "category_id";
case TYPE -> "type";
};
}
return switch (dimension) {
case DAY -> "DATE_FORMAT(transaction_time, '%Y-%m-%d')";
case WEEK -> "DATE_FORMAT(transaction_time, '%x-W%v')";
@@ -6,6 +6,7 @@ import cn.rosercode.finance.dto.LoginRequest;
import cn.rosercode.finance.dto.RegisterRequest;
import cn.rosercode.finance.entity.User;
import cn.rosercode.finance.mapper.UserMapper;
import cn.rosercode.finance.service.RegistrationService;
import cn.rosercode.finance.service.UserService;
import cn.rosercode.finance.util.JwtUtil;
import cn.rosercode.finance.vo.LoginVO;
@@ -22,11 +23,16 @@ public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final JwtUtil jwtUtil;
private final DefaultDataInitializer defaultDataInitializer;
private final RegistrationService registrationService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil) {
public UserServiceImpl(UserMapper userMapper, JwtUtil jwtUtil, DefaultDataInitializer defaultDataInitializer,
RegistrationService registrationService) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
this.defaultDataInitializer = defaultDataInitializer;
this.registrationService = registrationService;
}
/**
@@ -66,6 +72,11 @@ public class UserServiceImpl implements UserService {
@Override
@Transactional
public void register(RegisterRequest request) {
// 注册功能关闭且已存在用户时,拒绝注册(保留首个用户创建能力)
if (!registrationService.isRegistrationAllowed()) {
throw new BusinessException(403, "注册功能已关闭");
}
// 检查用户名是否已被注册
User existing = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, request.getUsername()));
@@ -81,6 +92,9 @@ public class UserServiceImpl implements UserService {
// 如果未提供昵称,则使用用户名作为默认昵称
user.setNickname(request.getNickname() != null ? request.getNickname() : request.getUsername());
userMapper.insert(user);
// 为新用户初始化默认分类与标签
defaultDataInitializer.initializeForUser(user.getId());
}
/**
@@ -0,0 +1,18 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 建议新建标签视图对象,携带标签名称与颜色。
*
* @date 2026-09-18 10:50:03
*/
@Data
public class AiNewTagVO {
/** 建议新建的标签名称 */
private String name;
/** 建议新建的标签颜色(十六进制) */
private String color;
}
@@ -33,6 +33,15 @@ public class AiSuggestionVO {
/** 建议标签名称列表 */
private List<String> tagNames;
/** 建议新建的分类名称,为 null 表示不新建分类 */
private String newCategoryName;
/** 建议新建的分类图标(Emoji) */
private String newCategoryIcon;
/** 建议新建的标签列表,为空表示不新建标签 */
private List<AiNewTagVO> newTags;
/** 建议理由 */
private String reason;
}
@@ -0,0 +1,43 @@
package cn.rosercode.finance.vo;
import cn.rosercode.finance.config.datasource.DataSourceType;
import lombok.Data;
/**
* 数据库状态视图对象,用于向前端展示当前数据库配置情况。
*
* @date 2026-09-18 11:01:00
*/
@Data
public class DatabaseStatusVO {
/** 是否已完成数据库配置 */
private boolean configured;
/** 数据库类型 */
private DataSourceType type;
/** 数据库类型显示名称 */
private String typeName;
/** 脱敏后的连接摘要 */
private String summary;
/** 本地配置文件路径 */
private String configFile;
/** MySQL 主机地址(用于回显,仅 MySQL) */
private String host;
/** MySQL 端口(用于回显,仅 MySQL) */
private Integer port;
/** MySQL 数据库名(用于回显,仅 MySQL) */
private String database;
/** MySQL 用户名(用于回显,仅 MySQL) */
private String username;
/** SQLite 文件路径(用于回显,仅 SQLite) */
private String sqlitePath;
}
@@ -0,0 +1,21 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* 注册状态视图对象,用于向前端展示注册开关与是否允许注册。
*
* @date 2026-09-18 12:09:24
*/
@Data
public class RegistrationStatusVO {
/** 注册开关是否开启 */
private boolean enabled;
/** 当前是否为无任何用户的首个用户场景 */
private boolean firstUser;
/** 当前是否允许注册(开关开启或首个用户) */
private boolean allowed;
}
+3 -6
View File
@@ -1,16 +1,13 @@
spring:
application:
name: my-app
name: finance-app
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
# 数据源不再在此固定配置,改由运行时数据库配置(config/database.json)决定,
# 详见 cn.rosercode.finance.config.datasource 包。
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: cn.rosercode.finance.entity
+125
View File
@@ -0,0 +1,125 @@
-- SQLite 建表脚本
-- 说明:与 MySQL 版(init.sql)保持相同的表结构与语义,差异如下:
-- 1. 自增主键使用 INTEGER PRIMARY KEY AUTOINCREMENT
-- 2. 不支持列 COMMENT,故移除全部注释
-- 3. TINYINT(1) 使用 INTEGER,JSON 使用 TEXT,DECIMAL 使用 NUMERIC
-- 4. 索引使用独立的 CREATE INDEX 语句
-- 5. 时间默认值与触发器使用本地时间(SQLite 的 CURRENT_TIMESTAMP 为 UTC)
-- 语句分隔符为 ;;,以兼容 CREATE TRIGGER 内部的分号
CREATE TABLE IF NOT EXISTS t_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(50),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE TABLE IF NOT EXISTS t_category (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
type VARCHAR(10) NOT NULL,
icon VARCHAR(50),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_category_user_id ON t_category (user_id);;
CREATE INDEX IF NOT EXISTS idx_category_user_type ON t_category (user_id, type);;
CREATE TABLE IF NOT EXISTS t_transaction (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
category_id INTEGER,
type VARCHAR(10) NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
payment_method VARCHAR(30),
remark VARCHAR(255),
remark_hidden INTEGER NOT NULL DEFAULT 0,
external_no VARCHAR(64),
transaction_time DATETIME NOT NULL,
include_in_stats INTEGER NOT NULL DEFAULT 1,
raw_data TEXT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_transaction_user_id ON t_transaction (user_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_category_id ON t_transaction (category_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_time ON t_transaction (transaction_time);;
CREATE INDEX IF NOT EXISTS idx_transaction_user_time ON t_transaction (user_id, transaction_time);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_external ON t_transaction (user_id, external_no);;
CREATE TABLE IF NOT EXISTS t_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
color VARCHAR(20),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_tag_user_id ON t_tag (user_id);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_tag_user_name ON t_tag (user_id, name);;
CREATE TABLE IF NOT EXISTS t_transaction_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);;
CREATE TABLE IF NOT EXISTS t_ai_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
api_key VARCHAR(255),
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com',
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat',
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_ai_config_user_id ON t_ai_config (user_id);;
CREATE TABLE IF NOT EXISTS t_ai_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
model VARCHAR(50),
transaction_count INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 1,
error_message VARCHAR(500),
duration_ms INTEGER,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
-- 兜底触发器:当应用层未显式更新 updated_at 时,自动刷新为本地时间
CREATE TRIGGER IF NOT EXISTS trg_user_updated_at
AFTER UPDATE ON t_user
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_user SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_transaction_updated_at
AFTER UPDATE ON t_transaction
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_transaction SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_ai_config_updated_at
AFTER UPDATE ON t_ai_config
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_ai_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;