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
+226 -7
View File
@@ -8,9 +8,13 @@ plugins {
group = 'cn.rosercode'
version = '0.0.1-SNAPSHOT'
// 目标 JDK 版本:同时用于编译工具链与 jar 命名(区分不同 JDK 构建的产物)
def jdkVersion = 21
def jdkTag = "jdk${jdkVersion}"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(jdkVersion)
}
}
@@ -18,12 +22,23 @@ configurations {
compileOnly {
extendsFrom annotationProcessor
}
// 桌面启动器(GUI)专属运行时依赖,仅加入带 GUI 的 jar
guiRuntime
}
repositories {
mavenCentral()
}
// ===== 桌面启动器(GUI)依赖版本 =====
// JavaFX 与 dorkbox SystemTray 只服务于带 GUI 的 jar:
// - compileOnly:编译期可见,但不进入无 GUI 的普通 jar
// - guiRuntime:仅加入 bootGuiJar 的运行时 classpath
// JavaFX 21.0.2 官方发布 win / linux / mac / mac-aarch64 四个平台,
// 全部打包进 GUI jar,使其在 Windows、Linux、macOS(含 Apple Silicon)上均可运行。
def javafxVersion = '21.0.2'
def javafxPlatforms = ['win', 'linux', 'mac', 'mac-aarch64']
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
@@ -38,7 +53,9 @@ dependencies {
// SQLite 驱动,用于本地文件数据库
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.3'
implementation 'com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0'
// 接口文档:生产保留只读 JSON(/v3/api-docs);Knife4j / Swagger UI(doc.html)仅开发/测试环境
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.3.0'
developmentOnly 'com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0'
implementation 'com.alibaba:easyexcel:4.0.3'
implementation 'org.apache.commons:commons-csv:1.12.0'
@@ -47,9 +64,6 @@ dependencies {
// PDF 账单解析(招商银行「交易流水」PDF 为文本型,按字符坐标还原表格)
implementation 'org.apache.pdfbox:pdfbox:3.0.3'
// OpenAI Java SDK:调用 DeepSeek 等 OpenAI 兼容接口(通过自定义 baseUrl 兼容)
implementation 'com.openai:openai-java:4.65.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
@@ -65,6 +79,19 @@ dependencies {
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// 桌面启动器(GUI):JavaFX 界面 + 跨平台系统托盘,仅打入带 GUI 的 jar
// 编译期只需一套平台 API;运行时打包全部平台以支持跨系统运行
compileOnly "org.openjfx:javafx-base:${javafxVersion}:${javafxPlatforms[0]}"
compileOnly "org.openjfx:javafx-graphics:${javafxVersion}:${javafxPlatforms[0]}"
compileOnly "org.openjfx:javafx-controls:${javafxVersion}:${javafxPlatforms[0]}"
compileOnly 'com.dorkbox:SystemTray:4.2'
javafxPlatforms.each { platform ->
guiRuntime "org.openjfx:javafx-base:${javafxVersion}:${platform}"
guiRuntime "org.openjfx:javafx-graphics:${javafxVersion}:${platform}"
guiRuntime "org.openjfx:javafx-controls:${javafxVersion}:${platform}"
}
guiRuntime 'com.dorkbox:SystemTray:4.2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
@@ -75,22 +102,36 @@ tasks.named('test') {
// ===== 代码格式规范 =====
// 统一 Java 导入顺序:java/javax -> 第三方库 -> 项目包,组间空行分隔
// 同时移除未被引用的导入(removeUnusedImports 基于 google-java-format 实现)
spotless {
java {
target 'src/main/java/**/*.java', 'src/test/java/**/*.java'
// 统一行尾为 LF,避免 Windows 检出 CRLF 时全量校验失败(详见 .gitattributes)
lineEndings 'UNIX'
// '|' 表示同组导入不插入空行;'' 为未显式指定导入的兜底组(即所有第三方库)
importOrder('java|javax', '', 'cn.rosercode')
removeUnusedImports()
}
}
// 编译前校验导入规范(含未使用导入),使 ./reload.sh、./package.sh 等开发流程即时发现问题;
// 需要自动修复时执行 spotlessApply。
tasks.named('compileJava') {
dependsOn 'spotlessJavaCheck'
}
// ===== 前端构建与打包 =====
def frontendDir = file("${projectDir}/frontend")
def frontendDistDir = file("${frontendDir}/dist")
// 解析 npm 可执行文件:优先使用 NODE_HOME,其次使用 PATH 中的 npm
// 解析 npm 可执行文件:优先使用 NODE_HOME,其次使用 PATH 中的 npm;Windows 下为 npm.cmd
def resolveNpm = {
def nodeHome = System.getenv('NODE_HOME')
return nodeHome ? "${nodeHome}/bin/npm" : 'npm'
def isWindows = System.getProperty('os.name').toLowerCase().contains('win')
if (nodeHome) {
return isWindows ? "${nodeHome}/npm.cmd" : "${nodeHome}/bin/npm"
}
return isWindows ? 'npm.cmd' : 'npm'
}
// 安装前端依赖(node_modules 存在且 package.json 未变化时自动跳过)
@@ -120,7 +161,185 @@ tasks.register('frontendBuild', Exec) {
// 将前端产物打入可执行 jar 的静态资源目录,实现前后端一体化部署
tasks.named('bootJar') {
dependsOn 'frontendBuild'
// jar 文件名携带 JDK 版本,如 ledgerly-0.0.1-SNAPSHOT-jdk21.jar
archiveFileName = "ledgerly-${version}-${jdkTag}.jar"
from(frontendDistDir) {
into 'BOOT-INF/classes/static'
}
// 普通 jar 不包含桌面启动器,保持无 GUI、无 JavaFX 依赖
exclude 'cn/rosercode/ledgerly/launcher/**'
exclude 'launcher/**'
}
// 带 GUI 的 jar:以桌面启动器为入口,内嵌前端与后端
tasks.register('bootGuiJar', org.springframework.boot.gradle.tasks.bundling.BootJar) {
group = 'build'
description = '打包带 GUI 启动器的可执行 jar(含前端与后端)'
dependsOn 'frontendBuild'
// jar 文件名携带 JDK 版本,如 ledgerly-0.0.1-SNAPSHOT-jdk21-gui.jar
archiveFileName = "ledgerly-${version}-${jdkTag}-gui.jar"
mainClass = 'cn.rosercode.ledgerly.launcher.LauncherMain'
targetJavaVersion = JavaVersion.VERSION_21
// 复用 bootJar 的生产 classpath(已剔除 developmentOnly,如 devtools 与 Knife4j UI),仅额外加入 GUI 运行时
classpath = tasks.named('bootJar').get().classpath + configurations.guiRuntime
from(frontendDistDir) {
into 'BOOT-INF/classes/static'
}
// JavaFX 与 JNA 的原生库需先从嵌套 jar 解压,运行时才能加载
requiresUnpack '**/javafx-*.jar'
requiresUnpack '**/jna*.jar'
// 平台专属应用图标仅用于 jpackage,不随 jar 分发
exclude 'launcher/*.ico'
exclude 'launcher/*.icns'
}
// ===== jpackage 应用镜像打包 =====
// 将可执行 jar 分别打包为 app-image:免安装目录,自带精简运行时,双击即用。
// 用法:./gradlew jpackageAppImage(一次生成普通与 GUI 两个 app-image)
// 也可单独执行 ./gradlew jpackageServerAppImage 或 ./gradlew jpackageGuiAppImage
// 可选:-PappIcon=<图标文件> 覆盖应用图标(Windows 用 .ico、macOS 用 .icns、Linux 用 .png);
// 未指定时由 tools/IconGen.java 从 icon.png 动态生成对应平台图标(不入库二进制图标)
// 产物:build/jpackage/LedgerlyServer/(普通服务)、build/jpackage/Ledgerly/(GUI 启动器)
// 构建环境相关值,注册任务时计算一次
def isWindowsOs = System.getProperty('os.name').toLowerCase().contains('win')
def isMacOs = System.getProperty('os.name').toLowerCase().contains('mac')
// 从 Java 工具链获取 jpackage 可执行文件,保证与编译使用同一 JDK
def jpackageHome = project.extensions.getByType(org.gradle.jvm.toolchain.JavaToolchainService)
.launcherFor { languageVersion = JavaLanguageVersion.of(jdkVersion) }
.get().metadata.installationPath.asFile
def jpackageExecutable = new File(jpackageHome, "bin/jpackage${isWindowsOs ? '.exe' : ''}")
def javaExecutable = new File(jpackageHome, "bin/java${isWindowsOs ? '.exe' : ''}")
def generatedIconsDir = layout.buildDirectory.dir('jpackage/icons')
def appIconProp = findProperty('appIcon')
// 图标源不随源码分发:默认从 icon.url(阿里云 OSS 长期签名地址)下载,可用 -PiconUrl / -PsourceIcon 覆盖
def iconUrlFile = file('src/main/resources/launcher/icon.url')
def defaultAppIconUrl = iconUrlFile.exists() ? iconUrlFile.text.trim() : null
// 下载/准备图标源 PNG(build/jpackage/icon-src/icon.png)
tasks.register('downloadAppIcon') {
group = 'distribution'
description = '获取应用图标源 PNG(从 icon.url 下载或使用 -PsourceIcon)'
def sourceFile = layout.buildDirectory.file('jpackage/icon-src/icon.png').get().asFile
def localSource = findProperty('sourceIcon') ? file(findProperty('sourceIcon')) : null
if (localSource != null) {
inputs.file(localSource)
}
outputs.file(sourceFile)
doLast {
sourceFile.parentFile.mkdirs()
if (localSource != null) {
sourceFile.bytes = localSource.bytes
return
}
def url = findProperty('iconUrl') ?: defaultAppIconUrl
if (!url) {
logger.warn('未配置图标地址(icon.url / -PiconUrl)且未提供 -PsourceIcon,跳过图标下载')
return
}
logger.lifecycle("下载应用图标源:${url}")
def connection = new URL(url).openConnection()
connection.connectTimeout = 15000
connection.readTimeout = 30000
try {
connection.inputStream.withStream { input ->
sourceFile.withOutputStream { output -> output << input }
}
} catch (Exception e) {
sourceFile.delete()
logger.warn("下载应用图标失败:${e.message}")
}
}
}
// 由下载/提供的源 PNG 生成各平台图标(ico/icns/png),仅需纯 JDK,无需入库二进制图标
tasks.register('generateAppIcons', Exec) {
group = 'distribution'
description = '从 icon.png 生成 jpackage 各平台应用图标(ico/icns/png)'
dependsOn 'downloadAppIcon'
def generator = file('tools/IconGen.java')
def outDir = generatedIconsDir.get().asFile
def sourceFile = layout.buildDirectory.file('jpackage/icon-src/icon.png').get().asFile
inputs.file(generator)
inputs.file(sourceFile)
outputs.dir(outDir)
onlyIf { sourceFile.exists() }
commandLine javaExecutable.absolutePath, generator.absolutePath, sourceFile.absolutePath, outDir.absolutePath
}
// 注册单个 app-image 打包任务;consoleApp=true 时在 Windows 附带控制台窗口
def registerAppImageTask = { String taskName, String jarTaskName, String appName, String appDescription, boolean consoleApp ->
tasks.register(taskName, Exec) {
group = 'distribution'
description = "用 jpackage 将 ${jarTaskName} 打包为 app-image(应用名 ${appName})"
dependsOn jarTaskName, 'generateAppIcons'
def appImageDir = layout.buildDirectory.dir("jpackage/${appName}").get().asFile
// 暂存目录放在 app-image 目录之外,避免被 jpackage 当作输入内容
def stagingDir = layout.buildDirectory.dir("jpackage/.staging/${appName}")
def sourceJar = tasks.named(jarTaskName).flatMap { it.archiveFile }
// jpackage 要求版本号为纯数字(如 0.0.1),去掉 -SNAPSHOT 等后缀
def appVersion = project.version.toString().replaceAll(/-.*$/, '')
// jpackage 要求平台专属图标格式:Windows 用 .ico、macOS 用 .icns、Linux 用 .png
// 图标由 generateAppIcons 在执行期生成,这里仅推导路径;存在性判断延后到 doFirst,避免配置期文件尚未生成而误判为空
def explicitIcon = appIconProp ? file(appIconProp) : null
def defaultIcon = new File(generatedIconsDir.get().asFile,
isWindowsOs ? 'icon.ico' : (isMacOs ? 'icon.icns' : 'icon.png'))
inputs.file(sourceJar)
if (explicitIcon != null) {
inputs.file(explicitIcon)
}
outputs.dir(appImageDir)
doFirst {
def jar = sourceJar.get().asFile
def staging = stagingDir.get().asFile
// jpackage 会将 --input 目录下所有文件纳入应用,先清空并仅放入待打包 jar
delete staging
delete appImageDir
appImageDir.parentFile.mkdirs()
staging.mkdirs()
copy {
from jar
into staging
}
def jpackageArgs = [
jpackageExecutable.absolutePath,
'--type', 'app-image',
'--name', appName,
'--input', staging.absolutePath,
'--main-jar', jar.name,
'--dest', appImageDir.parentFile.absolutePath,
'--app-version', appVersion,
'--vendor', 'rosercode',
'--description', appDescription,
// 运行时模块:Java SE 全集 + Spring Boot / JavaFX / JNA 反射与原生库所需
'--add-modules', 'java.se,jdk.unsupported,jdk.crypto.ec,jdk.localedata,jdk.charsets'
]
def icon = explicitIcon != null ? explicitIcon : defaultIcon
if (icon.exists()) {
jpackageArgs.addAll(['--icon', icon.absolutePath])
} else {
logger.warn("未找到应用图标,将使用默认图标:${icon}")
}
if (consoleApp && isWindowsOs) {
jpackageArgs.add('--win-console')
}
commandLine jpackageArgs
}
}
}
// 普通 jar:后端服务(Windows 下附带控制台窗口,便于查看日志)
registerAppImageTask('jpackageServerAppImage', 'bootJar', 'LedgerlyServer', '汇账 - 后端服务', true)
// GUI jar:带系统托盘的桌面启动器
registerAppImageTask('jpackageGuiAppImage', 'bootGuiJar', 'Ledgerly', '汇账 - 个人记账', false)
// 聚合任务:一次生成两个 app-image
tasks.register('jpackageAppImage') {
group = 'distribution'
description = '用 jpackage 将普通 jar 与 GUI jar 分别打包为 app-image'
dependsOn 'jpackageServerAppImage', 'jpackageGuiAppImage'
}