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:
+226
-7
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user