346 lines
16 KiB
Groovy
346 lines
16 KiB
Groovy
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'
|
||
}
|