feat: 桌面启动器、可执行 jar 瘦身与数据库配置实时进度

本次提交为「汇账」补充桌面 GUI 启动器与 jpackage 免安装打包,并对可执行 jar 做两项依赖瘦身(移除 OpenAI SDK、裁剪 Knife4j/Swagger UI),修复 Windows 构建兼容问题,最后将数据库「测试连接 / 保存并应用」改为 SSE 实时进度并新增进度弹框。

变更内容:
- 桌面启动器:新增 cn.rosercode.ledgerly.launcher(启停服务、日志实时查看、系统托盘、单实例锁、端口配置),普通 jar 经 exclude 排除该包以保持无 JavaFX 依赖。
- 打包:bootJar/bootGuiJar 文件名携带目标 JDK 版本;新增 jpackageAppImage(聚合)与 jpackageServerAppImage/jpackageGuiAppImage,基于 jpackage --type app-image 产出免安装应用,GUI 内嵌 win/linux/mac/mac-aarch64 四平台 JavaFX;应用图标不入库,打包时从 OSS 地址(launcher/icon.url)下载源图并由 tools/IconGen.java 生成平台图标(ico/icns/png)。
- 启动器图标:图标二进制不随源码分发,地址记录在 launcher/icon.url(阿里云 OSS 长期签名地址);运行时由 LauncherIconProvider 下载并缓存到 <基准目录>/cache/launcher-icon.png,缓存缺失时先直接用远端地址,下载失败静默降级为无图标。
- 运行目录:AppHome 新增 configDir/logsDir/setHome,config/logs/data 统一以基准目录(ledgerly.home 系统属性 / LEDGERLY_HOME 环境变量)解析,logback 同步支持。
- 依赖瘦身:移除 com.openai:openai-java,DeepSeekClient 改用 Spring RestClient 直连 Chat Completions(对外签名与 DeepSeekResponse 不变);Knife4j/Swagger UI 改为 developmentOnly,生产仅保留 /v3/api-docs JSON,Knife4jConfig 增加 @ConditionalOnClass 兜底。
- Windows 兼容:npm 解析兼容 npm.cmd;Spotless 统一 LF 并新增 .gitattributes。
- 数据库配置:新增 DatabaseConfigStreamService 与 POST /api/database/{test,config}/stream,SchemaInitializer/SchemaMigrator/DatabaseService 增加进度回调,前端弹框实时展示日志;数据库接口超时放宽为测试 60s、保存 120s。
- 缺陷修复:LauncherHome 改用 java.class.path 定位 jar,避免 Spring Boot 可执行 jar 下嵌套路径导致启动器崩溃;JwtInterceptor 跳过 SSE 完成后的 ASYNC 分派并加固响应已提交判断,避免首次无 Token 配置数据库时重复鉴权写入 401 触发 getOutputStream() 异常。
- 文档:README 精简为功能特性与快速开始/构建打包,细节说明迁移至本地 AGENTS.md。
This commit is contained in:
2026-09-24 14:43:01 +08:00
parent feb56c8a8d
commit ce71fd9b7a
35 changed files with 2745 additions and 315 deletions
@@ -1,32 +1,35 @@
package cn.rosercode.ledgerly.client;
import java.io.InterruptedIOException;
import java.net.SocketTimeoutException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeoutException;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.errors.OpenAIException;
import com.openai.models.ResponseFormatJsonObject;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.chat.completions.ChatCompletionContentPart;
import com.openai.models.chat.completions.ChatCompletionContentPartImage;
import com.openai.models.chat.completions.ChatCompletionContentPartText;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.completions.CompletionUsage;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import cn.rosercode.ledgerly.common.BusinessException;
/**
* DeepSeek 客户端,基于 OpenAI Java SDK 封装 Chat Completions 调用与响应解析。
* DeepSeek 客户端,基于 Spring {@link RestClient} 调用 OpenAI 兼容的 Chat Completions 接口。
*
* <p>DeepSeek 提供 OpenAI 兼容接口,因此复用官方 SDK,通过自定义 baseUrl 指向 DeepSeek,
* 由 SDK 负责请求构造与响应解析。对外保持 {@link #chat(String, String, String, String, String)}
* 签名与 {@link DeepSeekResponse} 不变。</p>
* <p>DeepSeek 提供 OpenAI 兼容接口,这里直接以 HTTP 方式调用 {@code {baseUrl}/chat/completions},
* 由 Spring 的 {@link RestClient} 负责请求发送与 JSON 序列化,避免引入体积庞大的官方 SDK。
* 对外保持 {@link #chat(String, String, String, String, String)} 签名与
* {@link DeepSeekResponse} 不变。</p>
*
* @date 2026-09-17 17:52:17
*/
@@ -40,14 +43,20 @@ public class DeepSeekClient {
/** 视觉请求的读取超时时间(图片识别耗时较长,单独放宽) */
private static final Duration IMAGE_REQUEST_TIMEOUT = Duration.ofSeconds(240);
/** 建立连接的超时时间 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** 缓存的客户端上限,超出后清空重建,避免按用户无限增长 */
private static final int MAX_CACHED_CLIENTS = 32;
/** 采样温度,保持较低随机性以获得稳定的结构化输出 */
private static final double TEMPERATURE = 0.2;
/** 按 baseUrl + apiKey 缓存的 SDK 客户端,避免每次调用重复构建 */
private final Map<String, OpenAIClient> clientCache = new ConcurrentHashMap<>();
/** Chat Completions 接口相对路径 */
private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
/** 按 baseUrl + apiKey + 超时 缓存的 HTTP 客户端,避免每次调用重复构建 */
private final Map<String, RestClient> clientCache = new ConcurrentHashMap<>();
/**
* 调用 Chat Completions 接口,返回模型输出的文本内容。
@@ -60,18 +69,16 @@ public class DeepSeekClient {
* @return 模型输出内容与 token 用量
*/
public DeepSeekResponse chat(String baseUrl, String apiKey, String model, String systemPrompt, String userPrompt) {
OpenAIClient client = resolveClient(baseUrl, apiKey, REQUEST_TIMEOUT);
// 强制返回 JSON 对象,与各 AI 服务的结构化输出约定一致
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
.model(model)
.temperature(TEMPERATURE)
.responseFormat(ResponseFormatJsonObject.builder().build())
.addSystemMessage(systemPrompt)
.addUserMessage(userPrompt)
.build();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("model", model);
payload.put("temperature", TEMPERATURE);
payload.put("response_format", Map.of("type", "json_object"));
payload.put("messages", List.of(
message("system", systemPrompt),
message("user", userPrompt)));
return execute(client, model, params);
return execute(resolveClient(baseUrl, apiKey, REQUEST_TIMEOUT), baseUrl, model, payload);
}
/**
@@ -90,98 +97,121 @@ public class DeepSeekClient {
*/
public DeepSeekResponse chatWithImages(String baseUrl, String apiKey, String model, String systemPrompt,
String userText, List<String> imageDataUrls) {
OpenAIClient client = resolveClient(baseUrl, apiKey, IMAGE_REQUEST_TIMEOUT);
List<ChatCompletionContentPart> parts = new ArrayList<>();
List<Map<String, Object>> content = new ArrayList<>();
if (userText != null && !userText.isBlank()) {
parts.add(ChatCompletionContentPart.ofText(
ChatCompletionContentPartText.builder().text(userText).build()));
content.add(Map.of("type", "text", "text", userText));
}
for (String dataUrl : imageDataUrls) {
parts.add(ChatCompletionContentPart.ofImageUrl(
ChatCompletionContentPartImage.builder()
.imageUrl(ChatCompletionContentPartImage.ImageUrl.builder().url(dataUrl).build())
.build()));
content.add(Map.of("type", "image_url", "image_url", Map.of("url", dataUrl)));
}
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
.model(model)
.temperature(TEMPERATURE)
.responseFormat(ResponseFormatJsonObject.builder().build())
.addSystemMessage(systemPrompt)
.addUserMessageOfArrayOfContentParts(parts)
.build();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("model", model);
payload.put("temperature", TEMPERATURE);
payload.put("response_format", Map.of("type", "json_object"));
payload.put("messages", List.of(
message("system", systemPrompt),
Map.of("role", "user", "content", content)));
return execute(client, model, params);
return execute(resolveClient(baseUrl, apiKey, IMAGE_REQUEST_TIMEOUT), baseUrl, model, payload);
}
/**
* 构造文本消息体。
*
* @param role 角色(system / user)
* @param content 文本内容
* @return 消息体
*/
private Map<String, Object> message(String role, String content) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("role", role);
message.put("content", content);
return message;
}
/**
* 执行 Chat Completions 请求并解析内容与 token 用量。
*
* @param client SDK 客户端
* @param model 模型名称(仅用于日志)
* @param params 请求参数
* @param client HTTP 客户端
* @param baseUrl API 基础地址
* @param model 模型名称(仅用于日志)
* @param payload 请求体
* @return 模型输出内容与 token 用量
*/
private DeepSeekResponse execute(OpenAIClient client, String model, ChatCompletionCreateParams params) {
private DeepSeekResponse execute(RestClient client, String baseUrl, String model, Map<String, Object> payload) {
long start = System.currentTimeMillis();
ChatCompletion completion;
JsonNode response;
try {
completion = client.chat().completions().create(params);
} catch (OpenAIException e) {
response = client.post()
.uri(normalizeBaseUrl(baseUrl) + CHAT_COMPLETIONS_PATH)
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(JsonNode.class);
} catch (RestClientException e) {
throw toBusinessException(model, e);
}
String content = completion.choices().stream()
.findFirst()
.flatMap(choice -> choice.message().content())
.orElse("");
String content = response == null ? ""
: response.path("choices").path(0).path("message").path("content").asText("");
if (content.isBlank()) {
throw new BusinessException("DeepSeek 返回内容为空");
}
DeepSeekResponse result = new DeepSeekResponse();
result.setContent(content);
completion.usage().ifPresent(usage -> fillUsage(result, usage));
if (response != null) {
fillUsage(result, response.path("usage"));
}
log.info("DeepSeek 调用完成: model={}, 耗时={}ms, tokens={}", model,
System.currentTimeMillis() - start, result.getTotalTokens());
return result;
}
/**
* 将 SDK 用量信息填充到响应对象。
* 将响应中的用量信息填充到结果对象。
*
* <p>DeepSeek 的缓存命中 token 使用私有字段 {@code prompt_cache_hit_tokens},
* SDK 标准模型未必能读取,缺失时保持为 0。</p>
* <p>缓存命中 token 优先取标准字段 {@code prompt_tokens_details.cached_tokens},
* 缺失时回退 DeepSeek 私有字段 {@code prompt_cache_hit_tokens}。</p>
*
* @param result 响应对象
* @param usage SDK 返回的用量信息
* @param usage 用量节点
*/
private void fillUsage(DeepSeekResponse result, CompletionUsage usage) {
result.setPromptTokens((int) usage.promptTokens());
result.setCompletionTokens((int) usage.completionTokens());
result.setTotalTokens((int) usage.totalTokens());
usage.promptTokensDetails()
.flatMap(CompletionUsage.PromptTokensDetails::cachedTokens)
.ifPresent(cached -> result.setCachedTokens(cached.intValue()));
private void fillUsage(DeepSeekResponse result, JsonNode usage) {
if (usage.isMissingNode() || usage.isNull()) {
return;
}
result.setPromptTokens(usage.path("prompt_tokens").asInt(0));
result.setCompletionTokens(usage.path("completion_tokens").asInt(0));
result.setTotalTokens(usage.path("total_tokens").asInt(0));
int cached = usage.path("prompt_tokens_details").path("cached_tokens").asInt(0);
if (cached == 0) {
cached = usage.path("prompt_cache_hit_tokens").asInt(0);
}
result.setCachedTokens(cached);
}
/**
* 将 SDK 异常转换为业务异常,并记录可定位的失败信息。
* 将 HTTP 异常转换为业务异常,并记录可定位的失败信息。
*
* <p>超时属于可预期的外部失败,返回面向用户的友好提示;其余情况保留原始错误消息。</p>
*
* @param model 模型名称
* @param e SDK 异常
* @param e HTTP 异常
* @return 业务异常
*/
private BusinessException toBusinessException(String model, OpenAIException e) {
private BusinessException toBusinessException(String model, RestClientException e) {
boolean timeout = isTimeout(e);
log.warn("调用 DeepSeek 失败: model={}, type={}, timeout={}, error={}, cause={}",
model, e.getClass().getSimpleName(), timeout, e.getMessage(), rootCauseMessage(e));
if (timeout) {
return new BusinessException("模型响应超时,请稍后重试或压缩图片后重试");
}
if (e instanceof RestClientResponseException responseException) {
return new BusinessException("调用 DeepSeek 失败: HTTP " + responseException.getStatusCode().value()
+ " " + responseException.getStatusText());
}
return new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
}
@@ -194,9 +224,9 @@ public class DeepSeekClient {
private boolean isTimeout(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof java.net.SocketTimeoutException
|| current instanceof java.util.concurrent.TimeoutException
|| current instanceof java.io.InterruptedIOException) {
if (current instanceof SocketTimeoutException
|| current instanceof TimeoutException
|| current instanceof InterruptedIOException) {
return true;
}
String name = current.getClass().getSimpleName();
@@ -226,30 +256,53 @@ public class DeepSeekClient {
}
/**
* 获取(并按需构建)对应配置的 SDK 客户端。
* 获取(并按需构建)对应配置的 HTTP 客户端。
*
* @param baseUrl API 基础地址
* @param apiKey API Key
* @param timeout 读取超时时间
* @return SDK 客户端
* @return HTTP 客户端
*/
private OpenAIClient resolveClient(String baseUrl, String apiKey, Duration timeout) {
String normalized = baseUrl != null && baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
String key = normalized + "\n" + apiKey + "\n" + timeout.toSeconds();
OpenAIClient cached = clientCache.get(key);
private RestClient resolveClient(String baseUrl, String apiKey, Duration timeout) {
String key = normalizeBaseUrl(baseUrl) + "\n" + apiKey + "\n" + timeout.toSeconds();
RestClient cached = clientCache.get(key);
if (cached != null) {
return cached;
}
if (clientCache.size() >= MAX_CACHED_CLIENTS) {
clientCache.clear();
}
return clientCache.computeIfAbsent(key, ignored -> OpenAIOkHttpClient.builder()
.baseUrl(normalized)
.apiKey(apiKey)
.timeout(timeout)
.maxRetries(0)
.build());
return clientCache.computeIfAbsent(key, ignored -> buildClient(apiKey, timeout));
}
/**
* 构建绑定认证头与超时配置的 HTTP 客户端。
*
* @param apiKey API Key
* @param timeout 读取超时时间
* @return HTTP 客户端
*/
private RestClient buildClient(String apiKey, Duration timeout) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(timeout);
return RestClient.builder()
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.build();
}
/**
* 归一化 API 基础地址,去掉末尾斜杠。
*
* @param baseUrl API 基础地址
* @return 归一化后的地址
*/
private String normalizeBaseUrl(String baseUrl) {
return baseUrl != null && baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
}
}
@@ -5,10 +5,18 @@ import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 接口文档(OpenAPI)配置。
*
* <p>依赖 Swagger 模型类,仅在类路径存在 springdoc/swagger 时生效;生产 jar 已裁剪 UI,
* 此处用 {@link ConditionalOnClass} 兜底,避免缺类导致启动失败。</p>
*/
@Configuration
@ConditionalOnClass(OpenAPI.class)
public class Knife4jConfig {
@Bean
@@ -3,16 +3,17 @@ package cn.rosercode.ledgerly.config.app;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.ledgerly.util.AppHome;
/**
* 应用级运行时配置的本地文件存储。
*
* <p>配置以 JSON 形式保存在工作目录下的 {@code config/app.json}。文件不存在或解析失败时
* <p>配置以 JSON 形式保存在应用基准目录下的 {@code config/app.json}。文件不存在或解析失败时
* 返回默认配置(注册关闭),保证在首次部署时注册功能默认禁用。</p>
*
* @date 2026-09-18 12:09:22
@@ -21,9 +22,6 @@ import org.springframework.stereotype.Component;
@Slf4j
public class AppConfigStore {
/** 配置目录 */
private static final String CONFIG_DIR = "config";
/** 配置文件名 */
private static final String CONFIG_FILE = "app.json";
@@ -39,7 +37,7 @@ public class AppConfigStore {
* @return 配置文件路径
*/
public Path getConfigFile() {
return Paths.get(CONFIG_DIR, CONFIG_FILE);
return AppHome.configDir().resolve(CONFIG_FILE);
}
/**
@@ -3,17 +3,18 @@ package cn.rosercode.ledgerly.config.datasource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Optional;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.ledgerly.util.AppHome;
/**
* 数据库配置的本地文件存储。
*
* <p>配置以 JSON 形式保存在工作目录下的 {@code config/database.json}。之所以不使用数据库存储,
* <p>配置以 JSON 形式保存在应用基准目录下的 {@code config/database.json}。之所以不使用数据库存储,
* 是因为应用需要在尚未连接任何数据库时就能读取配置并完成初始化,形成"先有配置、后连数据库"的顺序。</p>
*
* @date 2026-09-18 11:00:14
@@ -22,9 +23,6 @@ import org.springframework.stereotype.Component;
@Slf4j
public class DatabaseConfigStore {
/** 配置文件相对工作目录的位置 */
private static final String CONFIG_DIR = "config";
/** 配置文件名称 */
private static final String CONFIG_FILE = "database.json";
@@ -40,7 +38,7 @@ public class DatabaseConfigStore {
* @return 配置文件路径
*/
public Path getConfigFile() {
return Paths.get(CONFIG_DIR, CONFIG_FILE);
return AppHome.configDir().resolve(CONFIG_FILE);
}
/**
@@ -1,5 +1,6 @@
package cn.rosercode.ledgerly.config.datasource;
import java.util.function.Consumer;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
@@ -32,6 +33,17 @@ public class SchemaInitializer {
* @param type 数据库类型
*/
public void initialize(DataSource dataSource, DataSourceType type) {
initialize(dataSource, type, null);
}
/**
* 执行建表脚本,并通过回调推送进度。
*
* @param dataSource 目标数据源
* @param type 数据库类型
* @param progress 进度回调(面向用户的进度文本),可为空
*/
public void initialize(DataSource dataSource, DataSourceType type, Consumer<String> progress) {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.setContinueOnError(false);
@@ -42,8 +54,24 @@ public class SchemaInitializer {
String script = type == DataSourceType.MYSQL ? MYSQL_SCRIPT : SQLITE_SCRIPT;
populator.addScript(new ClassPathResource(script));
notifyProgress(progress, "开始执行 " + type.getDisplayName() + " 建表脚本:" + script);
log.info("开始执行 {} 建表脚本:{}", type.getDisplayName(), script);
long start = System.currentTimeMillis();
populator.execute(dataSource);
log.info("{} 建表脚本执行完成", type.getDisplayName());
long cost = System.currentTimeMillis() - start;
notifyProgress(progress, type.getDisplayName() + " 建表脚本执行完成,耗时 " + cost + "ms");
log.info("{} 建表脚本执行完成,耗时 {}ms", type.getDisplayName(), cost);
}
/**
* 推送进度,回调为空时忽略。
*
* @param progress 进度回调
* @param message 进度文本
*/
private void notifyProgress(Consumer<String> progress, String message) {
if (progress != null) {
progress.accept(message);
}
}
}
@@ -5,6 +5,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.function.Consumer;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
@@ -44,12 +45,27 @@ public class SchemaMigrator {
* @param type 数据库类型
*/
public void migrate(DataSource dataSource, DataSourceType type) {
migrate(dataSource, type, null);
}
/**
* 执行结构迁移,并通过回调推送进度。
*
* @param dataSource 目标数据源
* @param type 数据库类型
* @param progress 进度回调(面向用户的进度文本),可为空
*/
public void migrate(DataSource dataSource, DataSourceType type, Consumer<String> progress) {
notifyProgress(progress, "开始检查数据库结构迁移…");
long start = System.currentTimeMillis();
try (Connection connection = dataSource.getConnection()) {
renameChannelColumn(connection, type);
addVisionModelColumn(connection, type);
renameChannelColumn(connection, type, progress);
addVisionModelColumn(connection, type, progress);
} catch (SQLException e) {
throw new IllegalStateException("数据库结构迁移失败:" + e.getMessage(), e);
}
long cost = System.currentTimeMillis() - start;
notifyProgress(progress, "数据库结构迁移完成,耗时 " + cost + "ms");
}
/**
@@ -59,9 +75,11 @@ public class SchemaMigrator {
*
* @param connection 数据库连接
* @param type 数据库类型
* @param progress 进度回调,可为空
* @throws SQLException 迁移失败
*/
private void addVisionModelColumn(Connection connection, DataSourceType type) throws SQLException {
private void addVisionModelColumn(Connection connection, DataSourceType type, Consumer<String> progress)
throws SQLException {
if (columnExists(connection, type, AI_CONFIG_TABLE, VISION_MODEL_COLUMN)) {
return;
}
@@ -70,6 +88,7 @@ public class SchemaMigrator {
+ " VARCHAR(50) NOT NULL DEFAULT 'deepseek-flash'");
}
log.info("已为 {} 添加列 {}", AI_CONFIG_TABLE, VISION_MODEL_COLUMN);
notifyProgress(progress, "已为 " + AI_CONFIG_TABLE + " 添加列 " + VISION_MODEL_COLUMN);
}
/**
@@ -79,15 +98,18 @@ public class SchemaMigrator {
*
* @param connection 数据库连接
* @param type 数据库类型
* @param progress 进度回调,可为空
* @throws SQLException 迁移失败
*/
private void renameChannelColumn(Connection connection, DataSourceType type) throws SQLException {
private void renameChannelColumn(Connection connection, DataSourceType type, Consumer<String> progress)
throws SQLException {
if (!columnExists(connection, type, TRANSACTION_TABLE, OLD_CHANNEL_COLUMN)) {
return;
}
if (columnExists(connection, type, TRANSACTION_TABLE, NEW_CHANNEL_COLUMN)) {
log.warn("{} 同时存在 {} 与 {} 列,跳过重命名迁移",
TRANSACTION_TABLE, OLD_CHANNEL_COLUMN, NEW_CHANNEL_COLUMN);
notifyProgress(progress, TRANSACTION_TABLE + " 已存在新列 " + NEW_CHANNEL_COLUMN + ",跳过重命名迁移");
return;
}
try (Statement statement = connection.createStatement()) {
@@ -95,6 +117,20 @@ public class SchemaMigrator {
+ OLD_CHANNEL_COLUMN + " TO " + NEW_CHANNEL_COLUMN);
}
log.info("已将 {} 的列 {} 重命名为 {}", TRANSACTION_TABLE, OLD_CHANNEL_COLUMN, NEW_CHANNEL_COLUMN);
notifyProgress(progress, "已将 " + TRANSACTION_TABLE + " 的列 " + OLD_CHANNEL_COLUMN
+ " 重命名为 " + NEW_CHANNEL_COLUMN);
}
/**
* 推送进度,回调为空时忽略。
*
* @param progress 进度回调
* @param message 进度文本
*/
private void notifyProgress(Consumer<String> progress, String message) {
if (progress != null) {
progress.accept(message);
}
}
/**
@@ -5,18 +5,20 @@ 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;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.rosercode.ledgerly.common.Result;
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.service.DatabaseConfigStreamService;
import cn.rosercode.ledgerly.service.DatabaseService;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
/**
* 数据库配置控制器。
*
* <p>提供数据库状态查询、连接测试与配置保存接口。数据库未配置时这些接口允许匿名访问,
* 否则用户无法在无法登录的情况下完成初始化;一旦配置完成,则需携带 JWT 才能修改,
* 具体由 {@code JwtInterceptor} 动态控制。</p>
* <p>提供数据库状态查询、连接测试与配置保存接口,并额外提供基于 SSE 的进度推送接口。
* 数据库未配置时这些接口允许匿名访问,否则用户无法在无法登录的情况下完成初始化;
* 一旦配置完成,则需携带 JWT 才能修改,具体由 {@code JwtInterceptor} 动态控制。</p>
*
* @date 2026-09-18 11:01:23
*/
@@ -25,9 +27,12 @@ import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
public class DatabaseController {
private final DatabaseService databaseService;
private final DatabaseConfigStreamService databaseConfigStreamService;
public DatabaseController(DatabaseService databaseService) {
public DatabaseController(DatabaseService databaseService,
DatabaseConfigStreamService databaseConfigStreamService) {
this.databaseService = databaseService;
this.databaseConfigStreamService = databaseConfigStreamService;
}
/**
@@ -52,6 +57,17 @@ public class DatabaseController {
return Result.success();
}
/**
* 测试数据库连接(SSE 实时进度)。
*
* @param request 数据库配置请求
* @return SSE 发射器
*/
@PostMapping("/test/stream")
public SseEmitter testStream(@RequestBody DatabaseConfigRequest request) {
return databaseConfigStreamService.streamTest(request);
}
/**
* 保存数据库配置并切换到新的数据源。
*
@@ -62,4 +78,15 @@ public class DatabaseController {
public Result<DatabaseStatusVO> save(@RequestBody DatabaseConfigRequest request) {
return Result.success(databaseService.save(request));
}
/**
* 保存数据库配置并切换到新的数据源(SSE 实时进度)。
*
* @param request 数据库配置请求
* @return SSE 发射器
*/
@PostMapping("/config/stream")
public SseEmitter saveStream(@RequestBody DatabaseConfigRequest request) {
return databaseConfigStreamService.streamSave(request);
}
}
@@ -9,11 +9,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.ledgerly.common.BusinessException;
import cn.rosercode.ledgerly.util.AppHome;
/**
* 邮箱账单附件本地存储。
*
* <p>附件保存在工作目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}},
* <p>附件保存在应用基准目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}},
* 数据库仅记录相对路径;导入或忽略后不自动删除,便于追溯与重新解析。</p>
*
* @date 2026-09-19 16:43:56
@@ -22,8 +23,8 @@ import cn.rosercode.ledgerly.common.BusinessException;
@Slf4j
public class EmailBillStorage {
/** 附件存储根目录(相对工作目录) */
private static final Path ROOT = Paths.get("data", "email-bill");
/** 附件存储根目录(相对应用基准目录) */
private static final Path ROOT = AppHome.resolve("data", "email-bill");
/**
* 保存附件到本地,返回相对存储根目录的路径。
@@ -4,6 +4,7 @@ import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@@ -51,6 +52,12 @@ public class JwtInterceptor implements HandlerInterceptor {
return true;
}
// SSE 等异步请求完成后的 ASYNC 分派会再次进入拦截器,此时首次分派已完成鉴权,
// 且响应可能已提交(如 SSE 已写出),直接放行,避免重复鉴权写入 401 导致 IllegalStateException
if (request.getDispatcherType() == DispatcherType.ASYNC) {
return true;
}
// 数据库未配置时放行数据库配置接口,否则用户无法登录、陷入死锁;
// 配置完成后这些接口重新纳入 JWT 认证保护
String uri = request.getRequestURI();
@@ -72,6 +79,10 @@ public class JwtInterceptor implements HandlerInterceptor {
// Token 为空或验证不通过时,返回 401 未授权响应
if (token == null || !jwtUtil.validateToken(token)) {
// 响应已提交时无法再写入,避免二次写出触发 IllegalStateException
if (response.isCommitted()) {
return false;
}
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
Map<String, Object> result = new HashMap<>();
@@ -0,0 +1,102 @@
package cn.rosercode.ledgerly.launcher;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Path;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.stage.Stage;
/**
* 桌面启动器的 JavaFX 应用。
*
* <p>负责初始化单实例锁、主窗口、日志读取与系统托盘,并在退出时统一释放资源。</p>
*
* @date 2026-09-20 02:47:21
*/
public class LauncherApp extends Application {
private SingleInstanceGuard guard;
private LauncherConfigStore configStore;
private LauncherConfig config;
private ServerController serverController;
private LogTailer logTailer;
private TrayManager trayManager;
private LauncherFrame frame;
private Path home;
@Override
public void start(Stage stage) {
home = LauncherHome.resolve(getParameters().getNamed().get("home"));
LauncherHome.apply(home);
try {
guard = SingleInstanceGuard.acquire(home);
} catch (IOException e) {
showFatal("无法创建单实例锁:" + e.getMessage());
return;
}
if (guard == null) {
showFatal("程序已在运行,请勿重复启动。");
return;
}
configStore = new LauncherConfigStore(home);
config = configStore.load();
serverController = new ServerController();
URL iconUrl = LauncherIconProvider.resolve(home);
frame = new LauncherFrame(stage, serverController, config, configStore, home, iconUrl);
serverController.setStatusListener(status -> Platform.runLater(() -> frame.updateStatus(status)));
serverController.setErrorListener(error -> frame.showError("服务启动失败:" + rootMessage(error)));
frame.show();
logTailer = new LogTailer(home.resolve("logs").resolve("ledgerly.log"), frame::appendLog);
logTailer.start();
trayManager = new TrayManager(frame);
trayManager.init();
frame.setTray(trayManager);
}
@Override
public void stop() {
if (serverController != null) {
serverController.stopNow();
}
if (logTailer != null) {
logTailer.stop();
}
if (trayManager != null) {
trayManager.shutdown();
}
if (guard != null) {
guard.close();
}
}
private void showFatal(String message) {
Alert alert = new Alert(Alert.AlertType.WARNING);
alert.setTitle("提示");
alert.setHeaderText(null);
alert.setContentText(message);
alert.showAndWait();
Platform.exit();
}
private String rootMessage(Throwable error) {
Throwable cause = error;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
}
}
@@ -0,0 +1,55 @@
package cn.rosercode.ledgerly.launcher;
/**
* 桌面启动器的本地配置。
*
* <p>持久化在 {@code config/launcher.json},包含服务端口与若干交互开关。</p>
*
* @date 2026-09-20 02:47:21
*/
public class LauncherConfig {
/** 服务端口 */
private int serverPort = 8080;
/** 服务启动成功后是否自动打开浏览器 */
private boolean autoOpenBrowser = true;
/** 关闭窗口时是否最小化到系统托盘 */
private boolean closeToTray = true;
/** 日志窗口是否自动滚动到底部 */
private boolean logAutoScroll = true;
public int getServerPort() {
return serverPort;
}
public void setServerPort(int serverPort) {
this.serverPort = serverPort;
}
public boolean isAutoOpenBrowser() {
return autoOpenBrowser;
}
public void setAutoOpenBrowser(boolean autoOpenBrowser) {
this.autoOpenBrowser = autoOpenBrowser;
}
public boolean isCloseToTray() {
return closeToTray;
}
public void setCloseToTray(boolean closeToTray) {
this.closeToTray = closeToTray;
}
public boolean isLogAutoScroll() {
return logAutoScroll;
}
public void setLogAutoScroll(boolean logAutoScroll) {
this.logAutoScroll = logAutoScroll;
}
}
@@ -0,0 +1,74 @@
package cn.rosercode.ledgerly.launcher;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* 桌面启动器配置的本地文件存储。
*
* <p>配置以 JSON 形式保存在 {@code <home>/config/launcher.json};文件不存在或解析失败时
* 返回默认配置,保证首次运行可用。</p>
*
* @date 2026-09-20 02:47:21
*/
public class LauncherConfigStore {
/** 配置文件名 */
private static final String FILE_NAME = "launcher.json";
private final ObjectMapper objectMapper = new ObjectMapper();
private final Path file;
/**
* 构造配置存储。
*
* @param home 应用基准目录
*/
public LauncherConfigStore(Path home) {
this.file = home.resolve("config").resolve(FILE_NAME);
}
/**
* 获取配置文件路径。
*
* @return 配置文件路径
*/
public Path getFile() {
return file;
}
/**
* 读取配置,文件不存在或解析失败时返回默认配置。
*
* @return 启动器配置
*/
public LauncherConfig load() {
if (!Files.exists(file)) {
return new LauncherConfig();
}
try {
LauncherConfig config = objectMapper.readValue(file.toFile(), LauncherConfig.class);
return config != null ? config : new LauncherConfig();
} catch (IOException e) {
return new LauncherConfig();
}
}
/**
* 保存配置,自动创建父目录。
*
* @param config 启动器配置
*/
public void save(LauncherConfig config) {
try {
Files.createDirectories(file.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), config);
} catch (IOException e) {
// 配置保存失败不影响主流程,仅忽略
}
}
}
@@ -0,0 +1,393 @@
package cn.rosercode.ledgerly.launcher;
import java.awt.Desktop;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;
/**
* 桌面启动器主窗口。
*
* <p>提供服务启停、端口设置、打开浏览器、查看运行日志等操作入口,并负责根据
* 服务状态刷新界面与系统托盘提示。</p>
*
* @date 2026-09-20 02:47:21
*/
public class LauncherFrame {
/** 日志窗口保留的最大行数 */
private static final int MAX_LOG_LINES = 3000;
private final Stage stage;
private final ServerController serverController;
private final LauncherConfig config;
private final LauncherConfigStore configStore;
private final Path home;
private final URL iconUrl;
private final Label statusLabel = new Label();
private final Circle statusDot = new Circle(6);
private final TextField portField = new TextField();
private final CheckBox autoOpenBrowserBox = new CheckBox("启动后打开浏览器");
private final CheckBox autoScrollBox = new CheckBox("自动滚动");
private final TextArea logArea = new TextArea();
private final Button startButton = new Button("启动服务");
private final Button stopButton = new Button("停止服务");
private TrayManager tray;
private int logLineCount;
private boolean browserOpenedForRun;
/**
* 构造主窗口。
*
* @param stage 主舞台
* @param serverController 服务控制器
* @param config 启动器配置
* @param configStore 配置存储
* @param home 应用基准目录
* @param iconUrl 启动器图标地址(本地缓存或远端),可为 null
*/
public LauncherFrame(Stage stage, ServerController serverController,
LauncherConfig config, LauncherConfigStore configStore, Path home, URL iconUrl) {
this.stage = stage;
this.serverController = serverController;
this.config = config;
this.configStore = configStore;
this.home = home;
this.iconUrl = iconUrl;
build();
}
private void build() {
BorderPane root = new BorderPane();
root.setPadding(new Insets(12));
root.setTop(buildHeader());
root.setCenter(buildLogArea());
root.setBottom(buildActions());
Scene scene = new Scene(root, 720, 480);
stage.setScene(scene);
stage.setTitle("汇账");
if (iconUrl != null) {
stage.getIcons().add(new Image(iconUrl.toExternalForm()));
}
stage.setOnCloseRequest(event -> {
if (config.isCloseToTray() && tray != null && tray.isAvailable()) {
event.consume();
stage.hide();
} else {
exitApp();
}
});
applyStatus(ServerController.Status.STOPPED);
}
private VBox buildHeader() {
statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;");
HBox statusRow = new HBox(8, statusDot, statusLabel);
statusRow.setAlignment(Pos.CENTER_LEFT);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
Label portLabel = new Label("端口");
portField.setPrefColumnCount(6);
portField.setText(String.valueOf(config.getServerPort()));
portField.setTextFormatter(new TextFormatter<>(change ->
change.getControlNewText().matches("\\d{0,5}") ? change : null));
autoOpenBrowserBox.setSelected(config.isAutoOpenBrowser());
HBox controlRow = new HBox(8, spacer, portLabel, portField, autoOpenBrowserBox);
controlRow.setAlignment(Pos.CENTER_LEFT);
controlRow.setPadding(new Insets(8, 0, 8, 0));
return new VBox(statusRow, controlRow);
}
private TextArea buildLogArea() {
logArea.setEditable(false);
logArea.setWrapText(false);
logArea.setStyle("-fx-font-family: 'Consolas', 'Menlo', monospace; -fx-font-size: 12px;");
VBox.setVgrow(logArea, Priority.ALWAYS);
return logArea;
}
private HBox buildActions() {
startButton.setOnAction(e -> startServer());
stopButton.setOnAction(e -> stopServer());
Button browserButton = new Button("打开浏览器");
browserButton.setOnAction(e -> openBrowser());
Button logDirButton = new Button("打开日志目录");
logDirButton.setOnAction(e -> openLogDir());
Button exitButton = new Button("退出");
exitButton.setOnAction(e -> exitApp());
autoScrollBox.setSelected(config.isLogAutoScroll());
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
HBox actions = new HBox(8, startButton, stopButton, browserButton, logDirButton, spacer,
autoScrollBox, exitButton);
actions.setAlignment(Pos.CENTER_LEFT);
actions.setPadding(new Insets(8, 0, 0, 0));
return actions;
}
/**
* 显示窗口。
*/
public void show() {
stage.show();
}
/**
* 显示并置顶窗口。
*/
public void showWindow() {
stage.show();
stage.toFront();
stage.requestFocus();
}
/**
* 设置托盘管理器。
*
* @param tray 托盘管理器
*/
public void setTray(TrayManager tray) {
this.tray = tray;
}
/**
* 获取启动器图标地址。
*
* @return 图标地址,可为 null
*/
public URL getIconUrl() {
return iconUrl;
}
/**
* 启动服务。
*/
public void startServer() {
Integer port = parsePort();
if (port == null) {
showError("端口必须是 1-65535 之间的整数");
return;
}
config.setServerPort(port);
config.setAutoOpenBrowser(autoOpenBrowserBox.isSelected());
config.setLogAutoScroll(autoScrollBox.isSelected());
configStore.save(config);
browserOpenedForRun = false;
serverController.start(port);
}
/**
* 停止服务。
*/
public void stopServer() {
serverController.stop();
}
/**
* 在浏览器中打开服务地址。
*/
public void openBrowser() {
int port = serverController.isRunning() ? serverController.getPort() : parsePortOr(config.getServerPort());
openUri("http://localhost:" + port);
}
/**
* 打开日志目录。
*/
public void openLogDir() {
Path dir = home.resolve("logs");
try {
Files.createDirectories(dir);
} catch (IOException e) {
// 目录创建失败时仍尝试打开
}
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) {
Desktop.getDesktop().open(dir.toFile());
return;
}
} catch (Exception e) {
// 降级为提示
}
showInfo("日志目录:" + dir);
}
/**
* 退出应用。
*/
public void exitApp() {
serverController.stopNow();
Platform.exit();
}
/**
* 追加一行日志。
*
* @param line 日志行
*/
public void appendLog(String line) {
Platform.runLater(() -> {
logArea.appendText(line + "\n");
logLineCount++;
if (logLineCount > MAX_LOG_LINES) {
int index = logArea.getText().indexOf('\n');
if (index >= 0) {
logArea.deleteText(0, index + 1);
logLineCount--;
}
}
if (autoScrollBox.isSelected()) {
logArea.positionCaret(logArea.getLength());
logArea.setScrollTop(Double.MAX_VALUE);
}
});
}
/**
* 根据服务状态刷新界面。
*
* @param status 服务状态
*/
public void updateStatus(ServerController.Status status) {
applyStatus(status);
if (tray != null) {
tray.setStatus(statusText(status));
}
if (status == ServerController.Status.RUNNING
&& autoOpenBrowserBox.isSelected() && !browserOpenedForRun) {
browserOpenedForRun = true;
openBrowser();
}
}
/**
* 弹出错误提示。
*
* @param message 错误信息
*/
public void showError(String message) {
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("错误");
alert.setHeaderText(null);
alert.setContentText(message);
alert.initOwner(stage);
alert.showAndWait();
});
}
private void applyStatus(ServerController.Status status) {
statusLabel.setText(statusText(status));
statusDot.setFill(statusColor(status));
startButton.setDisable(status == ServerController.Status.STARTING
|| status == ServerController.Status.RUNNING
|| status == ServerController.Status.STOPPING);
stopButton.setDisable(status != ServerController.Status.RUNNING
&& status != ServerController.Status.STARTING);
portField.setDisable(status == ServerController.Status.STARTING
|| status == ServerController.Status.RUNNING
|| status == ServerController.Status.STOPPING);
}
private String statusText(ServerController.Status status) {
return switch (status) {
case STOPPED -> "已停止";
case STARTING -> "启动中…";
case RUNNING -> "运行中";
case STOPPING -> "停止中…";
case FAILED -> "启动失败";
};
}
private Color statusColor(ServerController.Status status) {
return switch (status) {
case STOPPED -> Color.web("#9e9e9e");
case STARTING, STOPPING -> Color.web("#fb8c00");
case RUNNING -> Color.web("#43a047");
case FAILED -> Color.web("#e53935");
};
}
private Integer parsePort() {
try {
int port = Integer.parseInt(portField.getText().trim());
return port >= 1 && port <= 65535 ? port : null;
} catch (NumberFormatException e) {
return null;
}
}
private int parsePortOr(int fallback) {
Integer port = parsePort();
return port != null ? port : fallback;
}
private void openUri(String uri) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(URI.create(uri));
return;
}
} catch (Exception e) {
// 降级为提示
}
showInfo("请在浏览器中访问:" + uri);
}
private void showInfo(String message) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setTitle("提示");
alert.setHeaderText(null);
alert.setContentText(message);
alert.initOwner(stage);
alert.showAndWait();
}
}
@@ -0,0 +1,150 @@
package cn.rosercode.ledgerly.launcher;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import cn.rosercode.ledgerly.util.AppHome;
/**
* 桌面启动器的基准目录解析。
*
* <p>解析优先级:命令行 {@code --home} 参数 → 环境变量 {@code LEDGERLY_HOME}
* → jar 所在目录 → 当前工作目录。解析结果会写入系统属性,供后端
* {@link AppHome} 与 logback 使用,保证双击启动时配置、日志、数据落点稳定。</p>
*
* @date 2026-09-20 02:47:21
*/
public final class LauncherHome {
/** 环境变量名,与 logback 使用的属性名保持一致 */
private static final String ENV_HOME = "LEDGERLY_HOME";
private LauncherHome() {
}
/**
* 解析应用基准目录。
*
* @param explicitHome 命令行指定的目录,可为 null
* @return 规范化后的基准目录绝对路径
*/
public static Path resolve(String explicitHome) {
if (explicitHome != null && !explicitHome.isBlank()) {
return Paths.get(explicitHome).toAbsolutePath().normalize();
}
String envHome = System.getenv(ENV_HOME);
if (envHome != null && !envHome.isBlank()) {
return Paths.get(envHome).toAbsolutePath().normalize();
}
Path jarDir = detectJarDir();
if (jarDir != null) {
return jarDir;
}
return Paths.get("").toAbsolutePath().normalize();
}
/**
* 将基准目录写入系统属性,供后端与 logback 读取。
*
* @param home 基准目录
*/
public static void apply(Path home) {
AppHome.setHome(home);
}
/**
* 探测应用 jar 所在目录。
*
* <p>不直接使用 {@code getProtectionDomain().getCodeSource()}:在 Spring Boot 可执行 jar
* 中它返回的是 jar 内部的嵌套虚拟路径({@code nested:}),对其创建文件会抛
* {@code Unable to resolve nested path}。这里优先从 {@code java.class.path} 取真实 jar,
* 其次兼容 jpackage app-image,最后才回退 code source 且仅接受本地 {@code file:} 路径。</p>
*
* @return jar 所在目录;无法探测时返回 null
*/
private static Path detectJarDir() {
Path fromClasspath = detectFromClasspath();
if (fromClasspath != null) {
return fromClasspath;
}
String appPath = System.getProperty("jpackage.app-path");
if (appPath != null && !appPath.isBlank()) {
Path binDir = Paths.get(appPath).getParent();
if (binDir != null && binDir.getParent() != null) {
return binDir.getParent();
}
}
return detectFromCodeSource();
}
/**
* 从 {@code java.class.path} 中探测真实 jar 所在目录。
*
* @return jar 所在目录;未找到返回 null
*/
private static Path detectFromClasspath() {
String classPath = System.getProperty("java.class.path");
if (classPath == null || classPath.isBlank()) {
return null;
}
for (String entry : classPath.split(File.pathSeparator)) {
if (entry.isBlank()) {
continue;
}
Path path = Paths.get(entry);
if (isLocalJar(path)) {
return path.toAbsolutePath().normalize().getParent();
}
}
return null;
}
/**
* 从 code source 探测,且仅接受本地文件系统路径。
*
* @return jar 所在目录;无法探测或为嵌套路径时返回 null
*/
private static Path detectFromCodeSource() {
try {
var source = LauncherHome.class.getProtectionDomain().getCodeSource();
if (source == null || source.getLocation() == null) {
return null;
}
Path path = Paths.get(source.getLocation().toURI());
if (!isLocalFile(path)) {
return null;
}
return Files.isRegularFile(path) ? path.getParent() : path;
} catch (Exception e) {
return null;
}
}
/**
* 判断路径是否为本地文件系统上的真实文件,排除 jar 内部虚拟路径。
*
* @param path 待判断路径
* @return 本地且存在返回 true
*/
private static boolean isLocalFile(Path path) {
try {
// 先判断协议,避免对 nested:/jar: 路径执行文件操作
return "file".equalsIgnoreCase(path.getFileSystem().provider().getScheme())
&& Files.exists(path);
} catch (Exception e) {
return false;
}
}
/**
* 判断路径是否为本地文件系统上的真实 jar 文件。
*
* @param path 待判断路径
* @return 真实 jar 返回 true
*/
private static boolean isLocalJar(Path path) {
return path.toString().endsWith(".jar") && isLocalFile(path) && Files.isRegularFile(path);
}
}
@@ -0,0 +1,156 @@
package cn.rosercode.ledgerly.launcher;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
/**
* 桌面启动器图标提供者。
*
* <p>图标资源不随源码/jar 分发,而是托管在远端(阿里云 OSS)。启动时优先使用本地缓存
* {@code <home>/cache/launcher-icon.png};缓存缺失时直接返回远端地址供窗口/托盘加载,
* 同时在后台下载并写入缓存,便于后续离线使用。下载失败时静默降级(不显示图标,不影响启动)。</p>
*
* @date 2026-09-24 14:36:55
*/
@Slf4j
public final class LauncherIconProvider {
/** 图标远端地址所在资源(classpath) */
private static final String URL_RESOURCE = "/launcher/icon.url";
/** 覆盖图标远端地址的系统属性 */
private static final String URL_PROPERTY = "ledgerly.icon.url";
/** 缓存目录名(相对应用基准目录) */
private static final String CACHE_DIR = "cache";
/** 缓存文件名 */
private static final String CACHE_FILE = "launcher-icon.png";
/** 连接超时 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** 读取超时 */
private static final Duration READ_TIMEOUT = Duration.ofSeconds(20);
private LauncherIconProvider() {
}
/**
* 解析启动器图标地址:优先本地缓存,其次远端地址(并触发后台缓存下载)。
*
* @param home 应用基准目录
* @return 可用的图标地址;无法获得时返回 null
*/
public static URL resolve(Path home) {
Path cached = cacheFile(home);
if (Files.exists(cached)) {
URL url = toUrl(cached.toUri().toString());
if (url != null) {
return url;
}
}
String remote = iconUrl();
if (remote == null) {
return null;
}
prefetchAsync(cached, remote);
return toUrl(remote);
}
/**
* 读取图标远端地址(系统属性优先,其次 classpath 资源)。
*
* @return 图标远端地址;未配置时返回 null
*/
public static String iconUrl() {
String override = System.getProperty(URL_PROPERTY);
if (override != null && !override.isBlank()) {
return override.trim();
}
try (InputStream in = LauncherIconProvider.class.getResourceAsStream(URL_RESOURCE)) {
if (in == null) {
return null;
}
String text = new String(in.readAllBytes(), StandardCharsets.UTF_8).trim();
return text.isEmpty() ? null : text;
} catch (Exception e) {
return null;
}
}
/**
* 获取缓存文件路径。
*
* @param home 应用基准目录
* @return 缓存文件路径
*/
private static Path cacheFile(Path home) {
return home.resolve(CACHE_DIR).resolve(CACHE_FILE);
}
/**
* 后台下载图标到缓存文件。
*
* @param cacheFile 缓存文件路径
* @param url 图标远端地址
*/
private static void prefetchAsync(Path cacheFile, String url) {
Thread thread = new Thread(() -> download(cacheFile, url), "ledgerly-icon-download");
thread.setDaemon(true);
thread.start();
}
/**
* 下载图标并写入缓存,失败时记录日志并忽略。
*
* @param cacheFile 缓存文件路径
* @param url 图标远端地址
*/
private static void download(Path cacheFile, String url) {
try {
HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(READ_TIMEOUT)
.GET()
.build();
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200 || response.body().length == 0) {
log.warn("下载启动器图标失败:HTTP {}", response.statusCode());
return;
}
Files.createDirectories(cacheFile.getParent());
Path temp = cacheFile.resolveSibling(CACHE_FILE + ".tmp");
Files.write(temp, response.body());
Files.move(temp, cacheFile, StandardCopyOption.REPLACE_EXISTING);
log.info("启动器图标已缓存:{}", cacheFile);
} catch (Exception e) {
log.warn("下载启动器图标失败:{}: {}", e.getClass().getSimpleName(), e.getMessage());
}
}
/**
* 字符串转 URL,失败返回 null。
*
* @param value 地址字符串
* @return URL;非法时返回 null
*/
private static URL toUrl(String value) {
try {
return URI.create(value).toURL();
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,28 @@
package cn.rosercode.ledgerly.launcher;
import javafx.application.Application;
/**
* 带 GUI 的 jar 的主入口。
*
* <p>与纯命令行启动({@code java -jar ledgerly-*.jar} 直接启动 Spring Boot)并存:
* 本入口先启动 JavaFX 桌面启动器,由用户在界面中按需启动 / 停止后端服务。</p>
*
* @date 2026-09-20 02:47:21
*/
public final class LauncherMain {
private LauncherMain() {
}
/**
* 程序入口。
*
* @param args 命令行参数,支持 {@code --home=<目录>} 指定基准目录
*/
public static void main(String[] args) {
// 关闭 AWT 无头模式,保证系统托盘可用
System.setProperty("java.awt.headless", "false");
Application.launch(LauncherApp.class, args);
}
}
@@ -0,0 +1,181 @@
package cn.rosercode.ledgerly.launcher;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* 日志文件增量读取器。
*
* <p>定时读取日志文件的新增内容并按行回调,供桌面启动器展示后端运行日志。
* 启动时先回放文件末尾若干行;当检测到文件被截断(如按天滚动)时自动从头读取。</p>
*
* @date 2026-09-20 02:47:21
*/
public class LogTailer {
/** 启动时回放的最大行数 */
private static final int TAIL_LINES = 300;
/** 单次回放读取的最大字节数 */
private static final int TAIL_BYTES = 256 * 1024;
/** 轮询间隔(秒) */
private static final long POLL_INTERVAL_SECONDS = 1;
private final Path file;
private final Consumer<String> lineConsumer;
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "ledgerly-log-tailer");
thread.setDaemon(true);
return thread;
});
private long position;
private byte[] leftover = new byte[0];
/**
* 构造日志读取器。
*
* @param file 日志文件路径
* @param lineConsumer 行回调(在读取线程调用,调用方需自行切换到界面线程)
*/
public LogTailer(Path file, Consumer<String> lineConsumer) {
this.file = file;
this.lineConsumer = lineConsumer;
}
/**
* 启动读取。
*/
public void start() {
long initialSize = sizeOf();
readTail().forEach(lineConsumer);
position = Math.max(sizeOf(), initialSize);
leftover = new byte[0];
executor.scheduleWithFixedDelay(this::safePoll,
POLL_INTERVAL_SECONDS, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
/**
* 停止读取。
*/
public void stop() {
executor.shutdownNow();
}
private void safePoll() {
try {
poll();
} catch (Exception e) {
// 读取失败时等待下一轮重试
}
}
private void poll() {
if (!Files.exists(file)) {
return;
}
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
long size = channel.size();
if (size < position) {
position = 0;
leftover = new byte[0];
}
if (size == position) {
return;
}
int length = (int) (size - position);
ByteBuffer buffer = ByteBuffer.allocate(length);
channel.position(position);
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
// 读满缓冲区
}
position = size;
byte[] data = new byte[leftover.length + length];
System.arraycopy(leftover, 0, data, 0, leftover.length);
System.arraycopy(buffer.array(), 0, data, leftover.length, length);
int lastNewline = lastNewline(data);
if (lastNewline < 0) {
leftover = data;
return;
}
emit(new String(data, 0, lastNewline, StandardCharsets.UTF_8));
leftover = Arrays.copyOfRange(data, lastNewline + 1, data.length);
} catch (IOException e) {
// 文件暂不可读时等待下一轮
}
}
private void emit(String text) {
for (String line : text.split("\n", -1)) {
if (line.endsWith("\r")) {
line = line.substring(0, line.length() - 1);
}
if (!line.isEmpty()) {
lineConsumer.accept(line);
}
}
}
private List<String> readTail() {
if (!Files.exists(file)) {
return List.of();
}
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
long size = channel.size();
long start = Math.max(0, size - TAIL_BYTES);
int length = (int) (size - start);
ByteBuffer buffer = ByteBuffer.allocate(length);
channel.position(start);
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
// 读满缓冲区
}
String[] lines = new String(buffer.array(), 0, length, StandardCharsets.UTF_8).split("\n");
List<String> result = new ArrayList<>();
for (int i = Math.max(0, lines.length - TAIL_LINES); i < lines.length; i++) {
String line = lines[i].endsWith("\r")
? lines[i].substring(0, lines[i].length() - 1) : lines[i];
if (!line.isEmpty()) {
result.add(line);
}
}
return result;
} catch (IOException e) {
return List.of();
}
}
private long sizeOf() {
try {
return Files.exists(file) ? Files.size(file) : 0;
} catch (IOException e) {
return 0;
}
}
private int lastNewline(byte[] data) {
for (int i = data.length - 1; i >= 0; i--) {
if (data[i] == '\n') {
return i;
}
}
return -1;
}
}
@@ -0,0 +1,187 @@
package cn.rosercode.ledgerly.launcher;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.util.function.Consumer;
import org.springframework.boot.Banner;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import cn.rosercode.ledgerly.LedgerlyApplication;
/**
* 后端服务的进程内控制器。
*
* <p>在桌面启动器所在的同一 JVM 中启动 / 关闭 Spring Boot 应用上下文。启动与关闭
* 均放在后台线程执行,避免阻塞 JavaFX 界面线程;状态变化通过监听器回调。</p>
*
* @date 2026-09-20 02:47:21
*/
public class ServerController {
/** 服务状态 */
public enum Status {
/** 已停止 */
STOPPED,
/** 启动中 */
STARTING,
/** 运行中 */
RUNNING,
/** 停止中 */
STOPPING,
/** 启动失败 */
FAILED
}
private ConfigurableApplicationContext context;
private volatile Status status = Status.STOPPED;
private volatile int port = 8080;
private Consumer<Status> statusListener;
private Consumer<Throwable> errorListener;
/**
* 设置状态监听器(在后台线程回调,调用方需自行切换到界面线程)。
*
* @param statusListener 状态监听器
*/
public void setStatusListener(Consumer<Status> statusListener) {
this.statusListener = statusListener;
}
/**
* 设置错误监听器。
*
* @param errorListener 错误监听器
*/
public void setErrorListener(Consumer<Throwable> errorListener) {
this.errorListener = errorListener;
}
/**
* 获取当前状态。
*
* @return 服务状态
*/
public Status getStatus() {
return status;
}
/**
* 获取当前端口。
*
* @return 服务端口
*/
public int getPort() {
return port;
}
/**
* 判断服务是否运行中。
*
* @return 运行中返回 true
*/
public boolean isRunning() {
return context != null && context.isActive();
}
/**
* 异步启动服务。
*
* @param port 服务端口
*/
public synchronized void start(int port) {
if (isRunning() || status == Status.STARTING) {
return;
}
this.port = port;
updateStatus(Status.STARTING);
Thread thread = new Thread(() -> doStart(port), "ledgerly-server-start");
thread.setDaemon(true);
thread.start();
}
/**
* 异步停止服务。
*/
public synchronized void stop() {
if (context == null) {
updateStatus(Status.STOPPED);
return;
}
updateStatus(Status.STOPPING);
Thread thread = new Thread(this::doStop, "ledgerly-server-stop");
thread.setDaemon(true);
thread.start();
}
/**
* 同步停止服务,用于进程退出前的清理。
*/
public synchronized void stopNow() {
doStop();
}
private void doStart(int port) {
try {
if (!isPortFree(port)) {
throw new IllegalStateException("端口 " + port + " 已被占用,请更换端口后重试");
}
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(LedgerlyApplication.class)
.headless(false)
.bannerMode(Banner.Mode.OFF)
.run("--server.port=" + port);
context = ctx;
updateStatus(Status.RUNNING);
} catch (Throwable e) {
context = null;
updateStatus(Status.FAILED);
if (errorListener != null) {
errorListener.accept(e);
}
}
}
private void doStop() {
ConfigurableApplicationContext ctx = context;
context = null;
if (ctx != null) {
try {
ctx.close();
} catch (Throwable e) {
if (errorListener != null) {
errorListener.accept(e);
}
}
}
updateStatus(Status.STOPPED);
}
private void updateStatus(Status newStatus) {
this.status = newStatus;
if (statusListener != null) {
statusListener.accept(newStatus);
}
}
/**
* 检测端口是否可用。
*
* @param port 待检测端口
* @return 可用返回 true
*/
private boolean isPortFree(int port) {
try (ServerSocket socket = new ServerSocket()) {
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress("0.0.0.0", port));
return true;
} catch (IOException e) {
return false;
}
}
}
@@ -0,0 +1,67 @@
package cn.rosercode.ledgerly.launcher;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* 桌面启动器单实例守卫。
*
* <p>基于 {@code <home>/config/.launcher.lock} 的文件锁实现:同一时间只允许一个
* 启动器实例持有锁,避免重复启动造成端口与配置冲突。</p>
*
* @date 2026-09-20 02:47:21
*/
public class SingleInstanceGuard implements AutoCloseable {
/** 锁文件名 */
private static final String LOCK_FILE = ".launcher.lock";
private final FileChannel channel;
private final FileLock lock;
private SingleInstanceGuard(FileChannel channel, FileLock lock) {
this.channel = channel;
this.lock = lock;
}
/**
* 尝试获取单实例锁。
*
* @param home 应用基准目录
* @return 获取成功返回守卫对象;已被占用返回 null
* @throws IOException 创建锁文件失败
*/
public static SingleInstanceGuard acquire(Path home) throws IOException {
Path lockFile = home.resolve("config").resolve(LOCK_FILE);
Files.createDirectories(lockFile.getParent());
FileChannel channel = FileChannel.open(lockFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = channel.tryLock();
if (lock == null) {
channel.close();
return null;
}
return new SingleInstanceGuard(channel, lock);
}
@Override
public void close() {
try {
if (lock != null && lock.isValid()) {
lock.release();
}
} catch (IOException e) {
// 释放失败无补救手段,忽略
}
try {
channel.close();
} catch (IOException e) {
// 关闭失败无补救手段,忽略
}
}
}
@@ -0,0 +1,105 @@
package cn.rosercode.ledgerly.launcher;
import java.net.URL;
import javafx.application.Platform;
import dorkbox.systemTray.Menu;
import dorkbox.systemTray.MenuItem;
import dorkbox.systemTray.Separator;
import dorkbox.systemTray.SystemTray;
/**
* 桌面启动器的系统托盘管理。
*
* <p>基于 dorkbox SystemTray 实现跨平台托盘图标与右键菜单;不支持托盘的环境下
* 自动降级为仅窗口模式。</p>
*
* @date 2026-09-20 02:47:21
*/
public class TrayManager {
private final LauncherFrame frame;
private SystemTray systemTray;
/**
* 构造托盘管理器。
*
* @param frame 主窗口,用于承载菜单动作
*/
public TrayManager(LauncherFrame frame) {
this.frame = frame;
}
/**
* 初始化系统托盘。
*/
public void init() {
try {
systemTray = SystemTray.get();
} catch (Throwable e) {
systemTray = null;
return;
}
if (systemTray == null) {
return;
}
try {
URL icon = frame.getIconUrl();
if (icon != null) {
systemTray.setImage(icon);
}
systemTray.setTooltip("汇账");
systemTray.setStatus("已停止");
Menu menu = systemTray.getMenu();
menu.add(new MenuItem("显示窗口", e -> Platform.runLater(frame::showWindow)));
menu.add(new MenuItem("启动服务", e -> Platform.runLater(frame::startServer)));
menu.add(new MenuItem("停止服务", e -> Platform.runLater(frame::stopServer)));
menu.add(new MenuItem("打开浏览器", e -> Platform.runLater(frame::openBrowser)));
menu.add(new Separator());
menu.add(new MenuItem("退出", e -> Platform.runLater(frame::exitApp)));
} catch (Throwable e) {
systemTray = null;
}
}
/**
* 判断托盘是否可用。
*
* @return 可用返回 true
*/
public boolean isAvailable() {
return systemTray != null;
}
/**
* 更新托盘状态文本。
*
* @param text 状态文本
*/
public void setStatus(String text) {
if (systemTray == null) {
return;
}
try {
systemTray.setStatus(text);
} catch (Throwable e) {
// 托盘状态更新失败可忽略
}
}
/**
* 关闭托盘。
*/
public void shutdown() {
if (systemTray == null) {
return;
}
try {
systemTray.shutdown();
} catch (Throwable e) {
// 托盘关闭失败可忽略
}
systemTray = null;
}
}
@@ -0,0 +1,160 @@
package cn.rosercode.ledgerly.service;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
/**
* 数据库配置操作的实时进度推送服务。
*
* <p>基于 SSE 将“测试连接”“保存并应用”的执行进度实时推送给前端:处理过程中逐条推送
* {@code progress} 事件(面向用户的中文进度文本),正常结束时推送 {@code done}
* (保存操作携带切换后的 {@link DatabaseStatusVO} JSON),异常时推送 {@code failed}
* (含失败原因)。实际处理在后台线程执行,避免阻塞请求线程。</p>
*
* @date 2026-09-24 13:53:41
*/
@Slf4j
@Service
public class DatabaseConfigStreamService {
private final DatabaseService databaseService;
private final ObjectMapper objectMapper;
/** 执行数据库配置任务的守护线程池 */
private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "database-config-stream");
thread.setDaemon(true);
return thread;
});
public DatabaseConfigStreamService(DatabaseService databaseService, ObjectMapper objectMapper) {
this.databaseService = databaseService;
this.objectMapper = objectMapper;
}
/**
* 建立“测试连接”的进度流。
*
* @param request 数据库配置请求
* @return SSE 发射器
*/
public SseEmitter streamTest(DatabaseConfigRequest request) {
SseEmitter emitter = new SseEmitter(0L);
executor.submit(() -> run(emitter, () -> {
Consumer<String> progress = line -> trySend(emitter, "progress", line);
databaseService.testConnection(request, progress);
trySend(emitter, "done", "{}");
}));
return emitter;
}
/**
* 建立“保存并应用”的进度流。
*
* @param request 数据库配置请求
* @return SSE 发射器
*/
public SseEmitter streamSave(DatabaseConfigRequest request) {
SseEmitter emitter = new SseEmitter(0L);
executor.submit(() -> run(emitter, () -> {
Consumer<String> progress = line -> trySend(emitter, "progress", line);
DatabaseStatusVO status = databaseService.save(request, progress);
trySend(emitter, "done", objectMapper.writeValueAsString(status));
}));
return emitter;
}
/**
* 应用关闭时停止线程池。
*/
@PreDestroy
public void shutdown() {
executor.shutdownNow();
}
/**
* 执行一次数据库配置任务并把结果推送到发射器。
*
* @param emitter SSE 发射器
* @param task 实际任务(负责推送进度与 done)
*/
private void run(SseEmitter emitter, StreamTask task) {
try {
task.execute();
} catch (RuntimeException | JsonProcessingException e) {
log.warn("数据库配置流式处理失败: error={}", e.getMessage());
trySend(emitter, "failed", "{\"message\":\"" + escape(e.getMessage()) + "\"}");
} finally {
complete(emitter);
}
}
/**
* 推送一个 SSE 事件,客户端已断开时静默失败。
*
* @param emitter SSE 发射器
* @param event 事件名
* @param data 事件数据
*/
private void trySend(SseEmitter emitter, String event, String data) {
try {
emitter.send(SseEmitter.event().name(event).data(data));
} catch (IOException | IllegalStateException e) {
// 连接已断开或发射器已结束,忽略
}
}
/**
* 结束 SSE 连接,忽略已完成或已断开的异常。
*
* @param emitter SSE 发射器
*/
private void complete(SseEmitter emitter) {
try {
emitter.complete();
} catch (Exception ignored) {
// 连接可能已断开
}
}
/**
* 转义 JSON 字符串中的特殊字符。
*
* @param value 原始字符串
* @return 转义后的字符串
*/
private String escape(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", " ")
.replace("\r", " ");
}
/**
* 可抛出异常、并声明受检 JsonProcessingException 的流式任务。
*/
@FunctionalInterface
private interface StreamTask {
/**
* 执行任务。
*
* @throws JsonProcessingException 结果序列化失败
*/
void execute() throws JsonProcessingException;
}
}
@@ -1,5 +1,7 @@
package cn.rosercode.ledgerly.service;
import java.util.function.Consumer;
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
@@ -22,7 +24,17 @@ public interface DatabaseService {
*
* @param request 数据库配置请求
*/
void testConnection(DatabaseConfigRequest request);
default void testConnection(DatabaseConfigRequest request) {
testConnection(request, null);
}
/**
* 测试数据库连接是否可用,并通过回调推送进度。
*
* @param request 数据库配置请求
* @param progress 进度回调(面向用户的进度文本),可为空
*/
void testConnection(DatabaseConfigRequest request, Consumer<String> progress);
/**
* 保存配置并切换到新的数据源,同时自动初始化表结构。
@@ -30,7 +42,18 @@ public interface DatabaseService {
* @param request 数据库配置请求
* @return 切换后的数据库状态
*/
DatabaseStatusVO save(DatabaseConfigRequest request);
default DatabaseStatusVO save(DatabaseConfigRequest request) {
return save(request, null);
}
/**
* 保存配置并切换到新的数据源,并通过回调推送进度。
*
* @param request 数据库配置请求
* @param progress 进度回调(面向用户的进度文本),可为空
* @return 切换后的数据库状态
*/
DatabaseStatusVO save(DatabaseConfigRequest request, Consumer<String> progress);
/**
* 应用启动时引导数据库:优先读取本地配置,其次读取环境变量。
@@ -6,6 +6,7 @@ import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.sql.DataSource;
@@ -29,6 +30,7 @@ import cn.rosercode.ledgerly.config.datasource.SchemaInitializer;
import cn.rosercode.ledgerly.config.datasource.SchemaMigrator;
import cn.rosercode.ledgerly.dto.DatabaseConfigRequest;
import cn.rosercode.ledgerly.service.DatabaseService;
import cn.rosercode.ledgerly.util.AppHome;
import cn.rosercode.ledgerly.vo.DatabaseStatusVO;
/**
@@ -83,16 +85,21 @@ public class DatabaseServiceImpl implements DatabaseService {
* 使用临时连接池验证连通性,验证完成后立即关闭,不影响当前数据源。
*/
@Override
public void testConnection(DatabaseConfigRequest request) {
public void testConnection(DatabaseConfigRequest request, Consumer<String> progress) {
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
notifyProgress(progress, "正在测试数据库连接:" + config.maskedSummary());
try (HikariDataSource dataSource = createDataSource(config)) {
assertConnectable(dataSource);
notifyProgress(progress, "数据库连接成功");
log.info("数据库连接测试成功:{}", config.maskedSummary());
} catch (BusinessException e) {
log.warn("数据库连接测试失败:{}", e.getMessage());
throw e;
} catch (Exception e) {
log.warn("数据库连接测试失败:{}", rootMessage(e));
throw new BusinessException("数据库连接失败:" + rootMessage(e));
}
}
@@ -100,33 +107,47 @@ public class DatabaseServiceImpl implements DatabaseService {
/**
* {@inheritDoc}
*
* 执行顺序:创建并测试新数据源 -> 建表 -> 持久化配置 -> 切换数据源。
* 执行顺序:创建并测试新数据源 -> 建表 -> 迁移 -> 持久化配置 -> 切换数据源。
* 任一步骤失败都会关闭新数据源并保持原有配置不变。
*/
@Override
public synchronized DatabaseStatusVO save(DatabaseConfigRequest request) {
public synchronized DatabaseStatusVO save(DatabaseConfigRequest request, Consumer<String> progress) {
long start = System.currentTimeMillis();
notifyProgress(progress, "开始应用数据库配置");
DatabaseConfig config = toConfig(request, currentConfig);
validate(config);
normalizeSqlitePath(config);
notifyProgress(progress, "配置校验通过,目标:" + config.maskedSummary());
log.info("开始应用数据库配置:{}", config.maskedSummary());
notifyProgress(progress, "正在创建连接池…");
HikariDataSource newDataSource = createDataSource(config);
boolean switched = false;
try {
notifyProgress(progress, "正在测试数据库连接…");
assertConnectable(newDataSource);
schemaInitializer.initialize(newDataSource, config.getType());
schemaMigrator.migrate(newDataSource, config.getType());
notifyProgress(progress, "数据库连接成功");
schemaInitializer.initialize(newDataSource, config.getType(), progress);
schemaMigrator.migrate(newDataSource, config.getType(), progress);
// 先落盘再切换,避免切换成功但配置未保存导致重启后状态不一致
notifyProgress(progress, "正在保存配置文件…");
configStore.save(config);
notifyProgress(progress, "正在切换数据源…");
dynamicDataSource.addAndSwitch(config.getType(), newDataSource);
switched = true;
currentConfig = config;
// 切换数据源后清空偏好缓存,避免读到旧库的数据
evictUserPreferenceCache();
log.info("数据库配置已更新并切换到 {}:{}", config.getType().getDisplayName(), config.maskedSummary());
long cost = System.currentTimeMillis() - start;
notifyProgress(progress, "数据库切换完成,耗时 " + cost + "ms");
log.info("数据库配置已更新并切换到 {}:{},耗时 {}ms",
config.getType().getDisplayName(), config.maskedSummary(), cost);
return buildStatus(config);
} catch (BusinessException e) {
log.warn("数据库配置失败:{}", e.getMessage());
throw e;
} catch (Exception e) {
log.warn("数据库配置失败:{}", rootMessage(e));
throw new BusinessException("数据库配置失败:" + rootMessage(e));
} finally {
if (!switched) {
@@ -265,7 +286,9 @@ public class DatabaseServiceImpl implements DatabaseService {
if (config.getType() != DataSourceType.SQLITE) {
return;
}
Path path = Paths.get(config.getSqlitePath()).toAbsolutePath().normalize();
// 相对路径以应用基准目录(默认工作目录,GUI 下为 jar 所在目录)为基准
Path rawPath = Paths.get(config.getSqlitePath());
Path path = (rawPath.isAbsolute() ? rawPath : AppHome.getHome().resolve(rawPath)).normalize();
try {
if (path.getParent() != null) {
Files.createDirectories(path.getParent());
@@ -391,6 +414,18 @@ public class DatabaseServiceImpl implements DatabaseService {
return vo;
}
/**
* 推送进度,回调为空时忽略。
*
* @param progress 进度回调
* @param message 进度文本
*/
private void notifyProgress(Consumer<String> progress, String message) {
if (progress != null) {
progress.accept(message);
}
}
/**
* 获取异常链中最底层异常的消息。
*
@@ -38,6 +38,21 @@ public final class AppHome {
return Paths.get(home);
}
/**
* 显式设置运行基准目录。
*
* <p>同时写入系统属性 {@code ledgerly.home} 与环境变量名 {@code LEDGERLY_HOME},
* 前者供本工具类解析、后者供 logback 的 {@code ${LEDGERLY_HOME}} 占位符解析,
* 由桌面启动器在进程启动早期调用。</p>
*
* @param home 基准目录
*/
public static void setHome(Path home) {
String value = home.toAbsolutePath().normalize().toString();
System.setProperty(HOME_PROPERTY, value);
System.setProperty(HOME_ENV, value);
}
/**
* 基于基准目录解析路径。
*
@@ -48,4 +63,22 @@ public final class AppHome {
public static Path resolve(String first, String... more) {
return getHome().resolve(Paths.get(first, more));
}
/**
* 获取配置目录。
*
* @return 配置目录路径
*/
public static Path configDir() {
return resolve("config");
}
/**
* 获取日志目录。
*
* @return 日志目录路径
*/
public static Path logsDir() {
return resolve("logs");
}
}
@@ -1,7 +1,6 @@
package cn.rosercode.ledgerly.util;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -48,7 +47,7 @@ public final class AppLogUtils {
* @return 规范化后的日志目录路径
*/
public static Path logDir() {
return Paths.get(LOG_DIR).toAbsolutePath().normalize();
return AppHome.logsDir();
}
/**
+1
View File
@@ -0,0 +1 @@
https://adccrac.oss-cn-beijing.aliyuncs.com/ledgerly%2Fassets%2Flauncher%2Ficon.png?OSSAccessKeyId=LTAI5tA21KsXJTQoSmt4m7YJ&Expires=2105591774&Signature=8%2BRYLWQb4vx7Js2eepd4a0Ut4Uc%3D
+3 -3
View File
@@ -8,11 +8,11 @@
</encoder>
</appender>
<!-- 文件输出 - 按天滚动 -->
<!-- 文件输出 - 按天滚动;LEDGERLY_HOME 由桌面启动器设置为 jar 所在目录,缺省为当前工作目录 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/ledgerly.log</file>
<file>${LEDGERLY_HOME:-.}/logs/ledgerly.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/ledgerly.%d{yyyy-MM-dd}.log</fileNamePattern>
<fileNamePattern>${LEDGERLY_HOME:-.}/logs/ledgerly.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>