plugins { id 'java' id 'org.springframework.boot' version '3.3.6' id 'io.spring.dependency-management' version '1.1.7' id 'com.diffplug.spotless' version '8.10.2' } group = 'cn.rosercode' version = '0.1' // 目标 JDK 版本:同时用于编译工具链与 jar 命名(区分不同 JDK 构建的产物) def jdkVersion = 21 def jdkTag = "jdk${jdkVersion}" java { toolchain { languageVersion = JavaLanguageVersion.of(jdkVersion) } } 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' implementation 'org.springframework.boot:spring-boot-starter-mail' // Thymeleaf(TEXT 模式):加载 classpath:/prompts 下的 AI 提示词模板 implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.security:spring-security-crypto' implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12' implementation 'com.baomidou:mybatis-plus-jsqlparser:3.5.12' runtimeOnly 'com.mysql:mysql-connector-j' // SQLite 驱动,用于本地文件数据库 runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.3' // 接口文档:生产保留只读 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' // zip 压缩包解析(支持带密码的 ZipCrypto / AES 加密压缩包) implementation 'net.lingala.zip4j:zip4j:2.11.5' // PDF 账单解析(招商银行「交易流水」PDF 为文本型,按字符坐标还原表格) implementation 'org.apache.pdfbox:pdfbox:3.0.3' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // MapStruct:编译期生成实体与视图对象之间的属性映射实现 implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' // 使 MapStruct 能识别 Lombok 生成的 getter/setter annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' 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' } tasks.named('test') { useJUnitPlatform() } // ===== 代码格式规范 ===== // 统一 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;Windows 下为 npm.cmd def resolveNpm = { def nodeHome = System.getenv('NODE_HOME') 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 未变化时自动跳过) tasks.register('npmInstall', Exec) { group = 'frontend' description = '安装前端依赖' workingDir frontendDir commandLine resolveNpm(), 'install' inputs.file("${frontendDir}/package.json") outputs.dir("${frontendDir}/node_modules") } // 构建前端产物 tasks.register('frontendBuild', Exec) { group = 'frontend' description = '构建前端产物(npm run build)' dependsOn 'npmInstall' workingDir frontendDir commandLine resolveNpm(), 'run', 'build' inputs.dir("${frontendDir}/src") inputs.file("${frontendDir}/package.json") inputs.file("${frontendDir}/index.html") inputs.file("${frontendDir}/vite.config.ts") outputs.dir(frontendDistDir) } // 将前端产物打入可执行 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' }