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