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
+15
View File
@@ -0,0 +1,15 @@
# 统一文本文件为 LF,避免 Windows 检出 CRLF 触发 Spotless 全量校验失败
* text=auto
*.java text eol=lf
*.gradle text eol=lf
*.xml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.properties text eol=lf
*.md text eol=lf
*.sql text eol=lf
*.ts text eol=lf
*.js text eol=lf
*.vue text eol=lf
*.json text eol=lf
*.sh text eol=lf
+24 -149
View File
@@ -1,167 +1,42 @@
# 汇账
个人汇账,支持收入/支出记录管理、分类与标签管理、多来源账单导入、自定义归类规则、AI 智能记账、月度统计与图表展示,并提供定时账单导入提醒与系统日志查看。
个人汇账应用:收支记录、分类标签、账单导入、归类规则、AI 智能记账、统计图表、定时提醒与系统日志。
## 功能特性
### 收支记录
- 收支记录的增删改查、分页与多条件筛选
- 批量设置分类、批量设置标签、批量删除
- 单条记录的「计入统计」开关与「备注隐藏」开关
- 按当前筛选条件导出 Excel
- 收支记录:增删改查、筛选分页、批量操作、Excel 导出,支持「计入统计」「备注隐藏」开关。
- 分类与标签:增删改查与默认数据,分类图标/标签颜色支持 AI 推荐。
- 交易渠道:内置渠道 + 自定义渠道(改名不影响历史账单,有账单使用时不可删除)。
- 统计与图表:月度收支、分类占比、收支趋势、分组统计与概览(ECharts)。
- 账单导入:支付宝/微信/美团/淘宝/京东/交行/招行,支持 zip(含加密压缩包),预览后确认导入并按交易号去重。
- 自定义规则:关键词、收支类型、金额区间组合,命中后自动应用分类与标签。
- AI 能力:分类/标签建议与一键采纳、自然语言查询与记账、图片记账(SSE 进度)、生成分类标签与图标颜色、用量统计。
- 定时提醒与通知:每天/每周/每月提醒,邮箱与 Server 酱推送。
- 系统日志:访问/登录日志查询,运行日志在线查看(SSE 实时推送)。
- 账号与多语言:JWT 登录、注册开关、运行时切换 SQLite/MySQL、中英日界面。
### 分类与标签
- 分类、标签的增删改查,新注册用户自动获得一套默认数据
- 分类图标支持 AI 推荐 Emoji,标签颜色支持 AI 推荐十六进制色值
## 快速开始
### 交易渠道
- 内置渠道:支付宝、微信、美团、淘宝、京东、招商银行、交通银行、现金
- 支持自定义渠道的新建、改名与删除(有账单使用时不可删除,改名不影响历史账单)
- 新增/编辑记录时可由 AI 推荐已有渠道或生成新渠道
### 统计与图表
- 月度收支统计、分类占比、收支趋势、分组统计与概览汇总
- 基于 ECharts 的可视化图表展示
### 账单导入
- 支持支付宝(CSV)、微信(xlsx)、美团、淘宝(xlsx)、京东(CSV)、交通银行、招商银行(PDF)账单解析
- 支持 zip 压缩包(含 ZipCrypto / AES 加密压缩包)上传
- 「解析预览 → 确认导入」两步流程,按交易号去重
### 自定义规则
- 基于关键词(匹配备注 / 交易对方 / 商品名称)、收支类型、金额区间组合的条件规则
- 命中后自动应用分类与标签,支持优先级排序与启用 / 停用
- 账单导入预览阶段展示命中的规则与建议分类 / 标签
### AI 能力(DeepSeek)
- 交易记录分类与标签的批量 / 单条智能建议,支持一键采纳
- 自然语言查询解析与自然语言记账解析
- 图片记账:一次上传最多 5 张账单 / 支付截图(支持粘贴),由视觉模型识别、SSE 实时推送进度,生成多条记录并标记疑似重复(按「类型+金额+时间」),提取原始字段,逐条预览勾选后批量导入
- AI 生成分类、标签,以及 Emoji / 颜色推荐
- AI 调用用量统计与明细查看
### 定时提醒与通知
- 按每天 / 每周 / 每月周期提醒导入新账单(默认每月 1 日)
- 多渠道推送:邮箱 SMTP(如 QQ 邮箱)、Server 酱(推送到个人微信)
- 渠道管理、测试发送与发送日志查看
### 系统日志
- 接口访问日志、登录日志的持久化与分页查询
- 后端运行日志在线查看,支持基于 SSE 的实时推送与筛选
### 账号与多语言
- JWT 登录认证,注册功能可开关
- 运行时数据库配置与切换(SQLite / MySQL),无需启动前准备
- 界面支持中文、英文、日文三种语言
## 技术栈
| 层级 | 技术 |
|---|---|
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus 3.5.12 + MySQL / SQLite |
| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts + Pinia |
| 构建 | Gradle 8.13 (后端) / npm (前端) |
| 认证 | JWT |
| 对象映射 | MapStruct 1.6.3 |
| 账单解析 | EasyExcel 4.0.3 + PDFBox 3.0.3 + Zip4j 2.11.5 |
| AI 能力 | DeepSeek |
| 通知渠道 | 邮箱 SMTP / Server 酱 |
| 多语言 | 中文 / 英文 / 日文 (vue-i18n) |
| API 文档 | Knife4j (OpenAPI 3) |
| JDK 版本 | 21.0.12.1 (Temurin LTS) |
| Node.js 版本 | v24.0.0 |
## 前置条件
- JDK 21.0.12.1 Temurin LTS,安装在 `D:\venv\jdk-21`
- Node.js v24.0.0
## 数据库配置
系统支持在服务启动后再配置数据库,无需启动前准备:
- 未检测到配置时,应用仍可正常启动,前端会引导进入「初始化数据库」页面;
- 支持 **SQLite**(本地单文件,零配置)与 **MySQL** 两种数据库;
- 保存配置后立即生效并自动建表,无需手动执行 SQL;
- 配置持久化在 `config/database.json`(已加入 `.gitignore`),SQLite 数据默认位于 `data/` 目录;
- 新注册用户会自动获得一套默认分类与标签;
- 兼容旧部署:若无本地配置但存在 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD` 环境变量,会自动作为首次配置并保存。
登录后可通过右上角用户菜单 →「数据库设置」随时切换数据库;切换后原数据库的数据保留,不做自动迁移。
## 启动后端
前置条件:JDK 21(Temurin)、Node.js。
```bash
cd ledgerly
set JAVA_HOME=D:\venv\jdk-21
# 后端(默认 8080,接口文档 http://localhost:8080/doc.html)
gradlew.bat bootRun
# 前端(默认 5173)
cd frontend && npm run dev
```
- 后端地址:http://localhost:8080
- API 文档:http://localhost:8080/doc.html
数据库无需预先准备:启动后在页面按引导选择 SQLite(本地文件,零配置)或 MySQL,保存后立即生效并自动建表;配置持久化于 `config/database.json`。
## 启动前端
## 构建与打包
```bash
cd ledgerly/frontend
npm run dev
gradle bootJar bootGuiJar # 普通 jar + 带 GUI 启动器的 jar,产物在 build/libs/
gradle jpackageAppImage # 免安装应用(普通 + GUI),产物在 build/jpackage/
```
- 前端地址:http://localhost:5173
- GUI jar 内嵌前后端与跨平台 JavaFX,单文件即可运行;配置与日志默认落在 jar 所在目录,可用 `--home=<目录>` 或环境变量 `LEDGERLY_HOME` 指定。
- app-image 自带运行时、免安装,但只能为**当前操作系统**生成。
## 构建可执行 jar(前后端一体化)
将前端构建产物一并打入 jar,单文件同时提供前端页面与后端接口:
```bash
gradle bootJar
```
- 该任务会先执行前端 `npm run build`,再把 `frontend/dist` 复制到 jar 内的 `BOOT-INF/classes/static/`
- 构建环境需具备 Node(开发容器通过 `NODE_HOME` 指向系统 nvm Node,npm 走本地 verdaccio 源)
- 输出文件:`build/libs/ledgerly-0.0.1-SNAPSHOT.jar`
启动(默认 8080,可另指定端口):
```bash
java -jar build/libs/ledgerly-0.0.1-SNAPSHOT.jar --server.port=18081
```
访问对应端口即可同时使用前端与接口;刷新 `/dashboard` 等子路由会由后端回退到 `index.html`。
## 项目结构
```
ledgerly/
├── build.gradle
├── src/main/java/cn/rosercode/ledgerly/
│ ├── client/ # 外部服务客户端(DeepSeek)
│ ├── common/ # 公共类
│ ├── config/ # 配置类(应用配置、动态数据源)
│ ├── controller/ # 接口层
│ ├── converter/ # MapStruct 对象转换器
│ ├── dto/ # 请求对象
│ ├── entity/ # 实体类
│ ├── enums/ # 枚举
│ ├── exporter/ # 导出处理
│ ├── importer/ # 账单解析器(支付宝/微信/美团/淘宝/京东/银行)
│ ├── interceptor/ # 拦截器(JWT、访问日志)
│ ├── mapper/ # 数据访问层
│ ├── notification/ # 通知渠道发送器
│ ├── scheduler/ # 定时任务
│ ├── service/ # 业务层(含 impl)
│ ├── util/ # 工具类
│ └── vo/ # 响应对象
├── src/main/resources/
│ ├── application.yml
│ ├── logback-spring.xml
│ ├── mapper/ # MyBatis XML
│ └── sql/ # 建表脚本(init.sql / schema-sqlite.sql 等)
└── frontend/ # Vue 3 前端
└── src/
├── api/ # API 请求封装
├── locales/ # 多语言文案
├── router/ # 路由配置
├── store/ # 状态管理
└── views/ # 页面组件
```
> 更详细的目录结构、启动器、jpackage、图标与开发环境说明见 `AGENTS.md`。
+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'
}
+3 -3
View File
@@ -36,10 +36,10 @@ export function getDatabaseStatus() {
/** 测试数据库连接是否可用 */
export function testDatabaseConnection(data: DatabaseConfigPayload) {
return request.post('/database/test', data)
return request.post('/database/test', data, { timeout: 60000 })
}
/** 保存数据库配置并切换到新数据源 */
/** 保存数据库配置并切换到新数据源(含建表初始化,耗时较长,单独放宽超时) */
export function saveDatabaseConfig(data: DatabaseConfigPayload) {
return request.post('/database/config', data)
return request.post('/database/config', data, { timeout: 120000 })
}
+177 -31
View File
@@ -1,17 +1,20 @@
<script setup lang="ts">
// 数据库配置面板:可复用于「系统设置」页与首次配置引导页。
// setup 为 true 时表示首次引导,保存成功后跳转登录页。
import { ref, onMounted } from 'vue'
// 「测试连接」与「保存并应用」均通过 SSE 实时推送后台进度,并以弹框展示,避免长时间无反馈。
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NForm, NFormItem, NInput, NInputNumber, NButton, NSpace,
NAlert, NTag, NRadioGroup, NRadioButton, NIcon, NText, NDescriptions, NDescriptionsItem,
NModal, NSpin,
} from 'naive-ui'
import { ServerOutline, SaveOutline, FlashOutline, FolderOpenOutline } from '@vicons/ionicons5'
import { getDatabaseStatus, testDatabaseConnection, saveDatabaseConfig } from '../api/database'
import { getDatabaseStatus } from '../api/database'
import type { DatabaseConfigPayload, DatabaseType } from '../api/database'
import { useDatabaseStore } from '../store/database'
import { openSse } from '../utils/sse'
const props = defineProps<{ setup?: boolean }>()
@@ -25,6 +28,15 @@ const saving = ref(false)
const configured = ref(false)
const hasPassword = ref(false)
// SSE 进度弹框状态
const streamVisible = ref(false)
const streamRunning = ref(false)
const streamTitle = ref('')
const streamLogs = ref<{ time: string; text: string }[]>([])
let streamController: AbortController | null = null
// 弹框关闭后执行的动作(如保存成功后的跳转/刷新),由用户手动关闭时触发
let afterClose: (() => void) | null = null
// 表单数据
const form = ref({
type: 'SQLITE' as DatabaseType,
@@ -92,42 +104,128 @@ function validate(): boolean {
return true
}
/** 测试数据库连接 */
async function handleTest() {
if (!validate()) return
testing.value = true
try {
await testDatabaseConnection(buildPayload())
message.success(t('database.testSuccess'))
} catch (e: any) {
message.error(e.message || t('database.testFailed'))
} finally {
testing.value = false
}
/** 追加一行进度日志 */
function pushLog(text: string) {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
streamLogs.value.push({ time, text })
}
/** 保存配置并切换到新数据库 */
async function handleSave() {
/**
* 发起一次带进度弹框的 SSE 数据库操作。
*
* @param url SSE 接口地址
* @param title 弹框标题
* @param onDone 成功回调(参数为 done 事件携带的数据)
* @param onFail 失败回调(用于复位按钮状态)
*/
function runStream(url: string, title: string, onDone: (data: string) => void, onFail: () => void) {
streamTitle.value = title
streamLogs.value = []
streamVisible.value = true
streamRunning.value = true
streamController = openSse(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload()),
onEvent: (event, data) => {
if (event === 'progress') {
pushLog(data)
} else if (event === 'done') {
pushLog(t('database.streamDone'))
streamRunning.value = false
onDone(data)
} else if (event === 'failed') {
let reason = data
try {
reason = JSON.parse(data)?.message || data
} catch {
// 非 JSON 时直接使用原文
}
pushLog(`${t('database.streamFailed')}${reason ? ':' + reason : ''}`)
streamRunning.value = false
onFail()
message.error(reason || t('database.saveFailed'))
}
},
onError: (e: any) => {
streamRunning.value = false
onFail()
message.error(e?.message || t('database.saveFailed'))
},
onClose: () => {
streamRunning.value = false
streamController = null
},
})
}
/** 关闭进度弹框(运行中不可关闭;关闭时执行挂起的后续动作) */
function closeStream() {
if (streamRunning.value) return
streamVisible.value = false
const action = afterClose
afterClose = null
action?.()
}
/** 测试数据库连接(SSE 进度) */
function handleTest() {
if (!validate()) return
testing.value = true
afterClose = null
runStream(
'/api/database/test/stream',
t('database.testingTitle'),
() => {
testing.value = false
message.success(t('database.testSuccess'))
},
() => {
testing.value = false
},
)
}
/** 保存配置并切换到新数据库(SSE 进度) */
function handleSave() {
if (!validate()) return
saving.value = true
try {
const res: any = await saveDatabaseConfig(buildPayload())
databaseStore.setStatus(res.data)
message.success(t('database.saveSuccess'))
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
await loadStatus()
}
} catch (e: any) {
message.error(e.message || t('database.saveFailed'))
} finally {
saving.value = false
}
afterClose = null
runStream(
'/api/database/config/stream',
t('database.applyingTitle'),
(data) => {
saving.value = false
try {
databaseStore.setStatus(JSON.parse(data))
} catch {
// 忽略解析失败
}
message.success(t('database.saveSuccess'))
// 由用户手动关闭弹框后再跳转/刷新,避免弹框被自动关闭
afterClose = () => {
if (props.setup) {
// 首次配置完成后引导用户登录
router.push('/login')
} else {
loadStatus()
}
}
},
() => {
saving.value = false
},
)
}
onMounted(loadStatus)
onBeforeUnmount(() => {
// 页面卸载时中断 SSE 连接(后台任务仍会执行至结束)
streamController?.abort()
})
</script>
<template>
@@ -224,6 +322,36 @@ onMounted(loadStatus)
</NSpace>
</NFormItem>
</NForm>
<!-- SSE 进度弹框:让用户了解后台正在运行 -->
<NModal
v-model:show="streamVisible"
preset="card"
:title="streamTitle"
style="width: 560px; max-width: 92vw;"
:closable="false"
:mask-closable="false"
:close-on-esc="false"
>
<NSpin :show="streamRunning">
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 8px;">
{{ t('database.streamHint') }}
</NText>
<div class="db-stream-logs">
<div v-for="(log, i) in streamLogs" :key="i" class="db-stream-item">
<span class="db-stream-time">{{ log.time }}</span>
<span class="db-stream-text">{{ log.text }}</span>
</div>
</div>
</NSpin>
<template #footer>
<NSpace justify="end">
<NButton :disabled="streamRunning" @click="closeStream">
{{ t('database.streamClose') }}
</NButton>
</NSpace>
</template>
</NModal>
</NCard>
</template>
@@ -234,4 +362,22 @@ onMounted(loadStatus)
border-radius: 8px;
background: rgba(0, 0, 0, 0.02);
}
.db-stream-logs {
max-height: 320px;
overflow-y: auto;
font-family: 'Consolas', 'Menlo', monospace;
font-size: 12px;
line-height: 1.7;
}
.db-stream-item {
display: flex;
gap: 8px;
}
.db-stream-time {
flex: none;
color: rgba(128, 128, 128, 0.9);
}
.db-stream-text {
word-break: break-all;
}
</style>
+6
View File
@@ -131,6 +131,12 @@ export default {
testFailed: { zh: '连接失败', en: 'Connection failed', ja: '接続に失敗しました' },
saveSuccess: { zh: '数据库配置已保存并生效', en: 'Database configuration saved and applied', ja: 'データベース設定を保存して適用しました' },
saveFailed: { zh: '数据库配置失败', en: 'Failed to configure database', ja: 'データベース設定に失敗しました' },
testingTitle: { zh: '正在测试数据库连接', en: 'Testing database connection', ja: 'データベース接続をテスト中' },
applyingTitle: { zh: '正在应用数据库配置', en: 'Applying database configuration', ja: 'データベース設定を適用中' },
streamHint: { zh: '后台正在处理,请保持窗口打开;完成后请点击“关闭”。', en: 'Processing in the background. Please keep this window open; click "Close" when it is done.', ja: 'バックグラウンドで処理中です。このウィンドウを開いたままにしてください。完了後に「閉じる」をクリックしてください。' },
streamDone: { zh: '处理完成', en: 'Completed', ja: '完了しました' },
streamFailed: { zh: '处理失败', en: 'Failed', ja: '失敗しました' },
streamClose: { zh: '关闭', en: 'Close', ja: '閉じる' },
loadFailed: { zh: '加载数据库状态失败', en: 'Failed to load database status', ja: 'データベース状態の読み込みに失敗しました' },
},
+4 -1
View File
@@ -9,6 +9,8 @@ export interface SseOptions {
method?: 'GET' | 'POST'
/** 请求体,POST 时使用(FormData 由浏览器自动设置 Content-Type) */
body?: BodyInit | null
/** 额外的请求头(如 JSON 请求体的 Content-Type),Authorization 由本工具统一附加 */
headers?: Record<string, string>
/** 收到事件时回调,event 为事件名,data 为已合并多行的数据 */
onEvent: (event: string, data: string) => void
/** 连接异常(含认证失败、网络错误)时回调 */
@@ -27,11 +29,12 @@ export interface SseOptions {
export function openSse(url: string, options: SseOptions): AbortController {
const controller = new AbortController()
const userStore = useUserStore()
const { method = 'GET', body } = options
const { method = 'GET', body, headers } = options
fetch(url, {
method,
headers: {
...(headers ?? {}),
Authorization: `Bearer ${userStore.token}`,
Accept: 'text/event-stream',
},
@@ -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>
+257
View File
@@ -0,0 +1,257 @@
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import javax.imageio.ImageIO;
/**
* 由单张 PNG 源图生成 jpackage 各平台所需的应用图标。
*
* <p>jpackage 对各平台图标格式要求不同:Windows 用 {@code .ico}、macOS 用
* {@code .icns}、Linux 用 {@code .png}。本工具基于纯 JDK(ImageIO + AWT)实现转换,
* 不依赖 ImageMagick/Pillow 等外部工具,可在 Windows/macOS/Linux 的构建机上直接运行。</p>
*
* <p>用法:{@code java IconGen.java <sourcePng> <outputDir>},输出
* {@code icon.png}、{@code icon.ico}、{@code icon.icns}。</p>
*/
public final class IconGen {
/** ICO 中包含的图标尺寸 */
private static final int[] ICO_SIZES = {16, 24, 32, 48, 64, 128, 256};
/** 尺寸不大于该值时使用 BMP 条目,否则使用 PNG 条目(兼容性最佳) */
private static final int ICO_BMP_MAX_SIZE = 64;
/** ICNS 条目:类型(4 字节)与其像素尺寸 */
private static final Object[][] ICNS_ENTRIES = {
{"ic11", 32},
{"ic12", 64},
{"ic07", 128},
{"ic13", 256},
{"ic08", 256},
{"ic14", 512},
{"ic09", 512},
};
private IconGen() {
}
/**
* 程序入口。
*
* @param args 源 PNG 路径与输出目录
* @throws IOException 读写失败
*/
public static void main(String[] args) throws IOException {
if (args.length < 2) {
System.err.println("用法: java IconGen.java <sourcePng> <outputDir>");
System.exit(2);
}
Path source = Paths.get(args[0]);
Path outputDir = Paths.get(args[1]);
Files.createDirectories(outputDir);
BufferedImage sourceImage = ImageIO.read(source.toFile());
if (sourceImage == null) {
throw new IOException("无法读取源图标:" + source);
}
Files.copy(source, outputDir.resolve("icon.png"), StandardCopyOption.REPLACE_EXISTING);
writeIco(sourceImage, outputDir.resolve("icon.ico"));
writeIcns(sourceImage, outputDir.resolve("icon.icns"));
System.out.println("已生成平台图标到 " + outputDir.toAbsolutePath());
}
/**
* 生成 Windows ICO 文件。
*
* @param source 源图
* @param out 输出文件
* @throws IOException 写入失败
*/
private static void writeIco(BufferedImage source, Path out) throws IOException {
int count = ICO_SIZES.length;
byte[][] images = new byte[count][];
for (int i = 0; i < count; i++) {
BufferedImage scaled = resize(source, ICO_SIZES[i]);
images[i] = ICO_SIZES[i] <= ICO_BMP_MAX_SIZE ? toBmpEntry(scaled) : toPng(scaled);
}
try (OutputStream os = Files.newOutputStream(out)) {
writeShortLE(os, 0);
writeShortLE(os, 1);
writeShortLE(os, count);
// 图标数据区起始偏移:6 字节文件头 + 每项 16 字节目录项
int offset = 6 + count * 16;
for (int i = 0; i < count; i++) {
int size = ICO_SIZES[i];
os.write(size >= 256 ? 0 : size);
os.write(size >= 256 ? 0 : size);
os.write(0);
os.write(0);
writeShortLE(os, 1);
writeShortLE(os, 32);
writeIntLE(os, images[i].length);
writeIntLE(os, offset);
offset += images[i].length;
}
for (byte[] image : images) {
os.write(image);
}
}
}
/**
* 将图像编码为 ICO 中的 BMP 条目:BITMAPINFOHEADER + 32 位 BGRA 像素 + AND 掩码。
*
* @param image 图像
* @return BMP 条目字节
* @throws IOException 编码失败
*/
private static byte[] toBmpEntry(BufferedImage image) throws IOException {
int width = image.getWidth();
int height = image.getHeight();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
// BITMAPINFOHEADER(高度为两倍,包含 XOR 图与 AND 掩码)
writeIntLE(buffer, 40);
writeIntLE(buffer, width);
writeIntLE(buffer, height * 2);
writeShortLE(buffer, 1);
writeShortLE(buffer, 32);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
writeIntLE(buffer, 0);
// XOR 图:32 位 BGRA,自下而上
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
int argb = image.getRGB(x, y);
buffer.write((argb) & 0xFF);
buffer.write((argb >> 8) & 0xFF);
buffer.write((argb >> 16) & 0xFF);
buffer.write((argb >>> 24) & 0xFF);
}
}
// AND 掩码:1 位/像素,行按 4 字节对齐,全 0(透明由 alpha 通道处理)
int maskRowBytes = ((width + 31) / 32) * 4;
byte[] maskRow = new byte[maskRowBytes];
for (int y = 0; y < height; y++) {
buffer.write(maskRow);
}
return buffer.toByteArray();
}
/**
* 生成 macOS ICNS 文件(以 PNG 条目承载各尺寸)。
*
* @param source 源图
* @param out 输出文件
* @throws IOException 写入失败
*/
private static void writeIcns(BufferedImage source, Path out) throws IOException {
ByteArrayOutputStream body = new ByteArrayOutputStream();
for (Object[] entry : ICNS_ENTRIES) {
byte[] png = toPng(resize(source, (Integer) entry[1]));
body.write(((String) entry[0]).getBytes("US-ASCII"));
writeIntBE(body, png.length + 8);
body.write(png);
}
byte[] bodyBytes = body.toByteArray();
try (OutputStream os = Files.newOutputStream(out)) {
os.write("icns".getBytes("US-ASCII"));
writeIntBE(os, bodyBytes.length + 8);
os.write(bodyBytes);
}
}
/**
* 缩放图像。
*
* @param source 源图
* @param size 目标边长
* @return 缩放后的 ARGB 图像
*/
private static BufferedImage resize(BufferedImage source, int size) {
BufferedImage target = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
graphics.drawImage(source, 0, 0, size, size, null);
} finally {
graphics.dispose();
}
return target;
}
/**
* 将图像编码为 PNG 字节。
*
* @param image 图像
* @return PNG 字节
* @throws IOException 编码失败
*/
private static byte[] toPng(BufferedImage image) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
ImageIO.write(image, "png", buffer);
return buffer.toByteArray();
}
/**
* 写出小端 2 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeShortLE(OutputStream os, int value) throws IOException {
os.write(value & 0xFF);
os.write((value >> 8) & 0xFF);
}
/**
* 写出小端 4 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeIntLE(OutputStream os, int value) throws IOException {
os.write(value & 0xFF);
os.write((value >> 8) & 0xFF);
os.write((value >> 16) & 0xFF);
os.write((value >>> 24) & 0xFF);
}
/**
* 写出大端 4 字节整数。
*
* @param os 输出流
* @param value 值
* @throws IOException 写入失败
*/
private static void writeIntBE(OutputStream os, int value) throws IOException {
os.write((value >>> 24) & 0xFF);
os.write((value >> 16) & 0xFF);
os.write((value >> 8) & 0xFF);
os.write(value & 0xFF);
}
}