前后端一体化部署时,前端使用 History 路由模式,直接访问或刷新 {@code /dashboard} 等
+ * 子路径时服务器上并不存在对应文件,会被解析为 404。本控制器在 404 时判断:若请求路径不是
+ * 接口或文档等资源路径,且不带文件扩展名,则转发到前端入口 {@code /index.html},交由前端路由处理。
+ * 其余情况仍返回 JSON 错误信息,避免影响接口与 API 文档。
+ *
+ * @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 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() : "";
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/SpaRouteSupport.java b/src/main/java/cn/rosercode/finance/config/SpaRouteSupport.java
new file mode 100644
index 0000000..4128525
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/SpaRouteSupport.java
@@ -0,0 +1,36 @@
+package cn.rosercode.finance.config;
+
+/**
+ * SPA 路由判断工具。
+ *
+ * 前后端一体化部署时,需要区分「前端 History 路由」与「接口 / 静态资源 / API 文档」请求。
+ * 前者在服务器上无对应文件,应回退到前端入口;后者应正常返回 404 或由对应处理器处理。
+ *
+ * @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(".");
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/WebConfig.java b/src/main/java/cn/rosercode/finance/config/WebConfig.java
index 8f480b5..5feb230 100644
--- a/src/main/java/cn/rosercode/finance/config/WebConfig.java
+++ b/src/main/java/cn/rosercode/finance/config/WebConfig.java
@@ -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");
}
/**
diff --git a/src/main/java/cn/rosercode/finance/config/app/AppConfig.java b/src/main/java/cn/rosercode/finance/config/app/AppConfig.java
new file mode 100644
index 0000000..c72b861
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/app/AppConfig.java
@@ -0,0 +1,20 @@
+package cn.rosercode.finance.config.app;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+/**
+ * 应用级运行时配置。
+ *
+ * 目前包含注册开关,持久化在本地文件 {@code config/app.json}。由于注册入口需要在
+ * 未登录状态下即可判断,且不希望依赖数据库,因此采用本地文件存储。
+ *
+ * @date 2026-09-18 12:09:20
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class AppConfig {
+
+ /** 是否开放注册:true 开放,false 关闭(默认关闭) */
+ private boolean registrationEnabled = false;
+}
diff --git a/src/main/java/cn/rosercode/finance/config/app/AppConfigStore.java b/src/main/java/cn/rosercode/finance/config/app/AppConfigStore.java
new file mode 100644
index 0000000..503203a
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/app/AppConfigStore.java
@@ -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;
+
+/**
+ * 应用级运行时配置的本地文件存储。
+ *
+ * 配置以 JSON 形式保存在工作目录下的 {@code config/app.json}。文件不存在或解析失败时
+ * 返回默认配置(注册关闭),保证在首次部署时注册功能默认禁用。
+ *
+ * @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);
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DataSourceContextHolder.java b/src/main/java/cn/rosercode/finance/config/datasource/DataSourceContextHolder.java
new file mode 100644
index 0000000..6b17c28
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DataSourceContextHolder.java
@@ -0,0 +1,71 @@
+package cn.rosercode.finance.config.datasource;
+
+/**
+ * 动态数据源上下文,保存当前生效的数据源标识与类型。
+ *
+ * 由于本系统为单实例个人应用,数据库为全局单例配置,因此使用静态 {@code volatile} 字段
+ * 而非 ThreadLocal 保存当前数据源。动态数据源在每次获取连接时都会读取这里的标识,
+ * 从而实现运行时热切换。
+ *
+ * @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;
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DataSourceType.java b/src/main/java/cn/rosercode/finance/config/datasource/DataSourceType.java
new file mode 100644
index 0000000..ad9f4dd
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DataSourceType.java
@@ -0,0 +1,46 @@
+package cn.rosercode.finance.config.datasource;
+
+/**
+ * 数据库类型枚举,标识当前支持的数据库种类。
+ *
+ * 每种类型携带对应的 JDBC 驱动类名与显示名称,供动态数据源构建连接时使用。
+ *
+ * @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;
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DatabaseBootstrap.java b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseBootstrap.java
new file mode 100644
index 0000000..69b76ae
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseBootstrap.java
@@ -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;
+
+/**
+ * 数据库启动引导器。
+ *
+ * 在 Spring 上下文准备就绪后触发一次数据库配置加载:优先使用本地保存的配置,
+ * 其次兼容环境变量。若均不存在,则保持未配置状态,等待用户通过设置页配置。
+ *
+ * @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();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfig.java b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfig.java
new file mode 100644
index 0000000..bd32e8a
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfig.java
@@ -0,0 +1,68 @@
+package cn.rosercode.finance.config.datasource;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+/**
+ * 数据库连接配置,描述一个可用的 MySQL 或 SQLite 数据源。
+ *
+ * 该对象会以 JSON 形式持久化到本地文件 {@code config/database.json},
+ * 因为应用启动时可能尚未连接任何数据库,无法把配置存入数据库本身。
+ *
+ * @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 连接地址。
+ *
+ * MySQL 地址附带常用参数:UTF-8 编码、关闭 SSL、上海时区、允许公钥检索,
+ * 并开启 {@code createDatabaseIfNotExist},便于首次连接自动建库。
+ *
+ * @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);
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfigStore.java b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfigStore.java
new file mode 100644
index 0000000..72d05c8
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DatabaseConfigStore.java
@@ -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;
+
+/**
+ * 数据库配置的本地文件存储。
+ *
+ * 配置以 JSON 形式保存在工作目录下的 {@code config/database.json}。之所以不使用数据库存储,
+ * 是因为应用需要在尚未连接任何数据库时就能读取配置并完成初始化,形成"先有配置、后连数据库"的顺序。
+ *
+ * @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 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());
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/DynamicDataSource.java b/src/main/java/cn/rosercode/finance/config/datasource/DynamicDataSource.java
new file mode 100644
index 0000000..a76b7e8
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/config/datasource/DynamicDataSource.java
@@ -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;
+
+/**
+ * 动态数据源,支持在运行时切换底层真实数据源。
+ *
+ * 继承 Spring 的 {@link AbstractRoutingDataSource},每次获取连接时根据
+ * {@link DataSourceContextHolder} 中的标识选择目标数据源。由于父类不暴露已注册的目标数据源,
+ * 本类自行维护一份映射,切换时同步到父类并触发 {@link #afterPropertiesSet()} 重建解析缓存,
+ * 同时关闭被替换的旧连接池。
+ *
+ * @date 2026-09-18 11:00:28
+ */
+public class DynamicDataSource extends AbstractRoutingDataSource {
+
+ private static final Logger log = LoggerFactory.getLogger(DynamicDataSource.class);
+
+ /** 自行维护的目标数据源映射,用于运行时增量注册与切换 */
+ private final Map