Files
ledgerly/build.gradle
T
rosercode 6439d336d4 chore: 前后端引入代码检查工具并整改存量问题
为统一前后端代码规范与质量,前端引入 ESLint + Prettier,后端在 Spotless 之外新增 Checkstyle 与 SpotBugs,检查接入构建与打包流程并失败即阻断,同时一次性整改全部存量告警,检查范围为后端主源码与前端源码。

变更内容:
- 前端新增 ESLint(flat config)与 Prettier 配置及 lint / lint:fix / format / format:check 脚本,并新增对应开发依赖
- 后端 build.gradle 应用 checkstyle 与 com.github.spotbugs 插件,新增 gradle/checkstyle/checkstyle.xml、suppressions.xml 与 gradle/spotbugs/exclude.xml
- 新增 Gradle frontendLint 任务并作为 frontendBuild 前置,新增 lint 聚合任务(Spotless + Checkstyle + SpotBugs + 前端 ESLint)
- 修复后端 Checkstyle 1 处(补花括号)与 SpotBugs 17 处(getParent() 判空、拆装箱冗余、异常捕获与日志、volatile 同步等),并按设计豁免值对象引用暴露等告警
- 修复前端规则问题(未使用变量、无意义赋值、v-for key、v-html 安全提示)并格式化存量文件
- README 补充依赖安装、代码检查与打包说明

说明:
- SpotBugs 工具版本固定为 4.8.6,规避其 BCEL 与 Spring Boot BOM 选定 commons-lang3 3.14.0 的 NoSuchMethodError
- 检查范围仅主源码,src/test/ 本地测试不纳入 Checkstyle/SpotBugs
- 前端格式化统一为无分号、单引号、行宽 120、LF,与 .gitattributes 保持一致
2026-10-12 13:06:10 +08:00

408 lines
18 KiB
Groovy
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import com.github.spotbugs.snom.Confidence
import com.github.spotbugs.snom.Effort
plugins {
id 'java'
id 'checkstyle'
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'
id 'com.github.spotbugs' version '6.5.12'
}
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'
}
// ===== 代码静态检查(Checkstyle / SpotBugs)=====
// 二者挂在 check 任务上,由 ./package.sh(gradle check)触发;
// 不进入 ./reload.sh 的快速热重载路径(compileJava),保持开发体验。
// 配置文件放在 gradle/ 下(项目根的 config/ 被 .gitignore 忽略,仅用于运行时配置)。
checkstyle {
configDirectory = file('gradle/checkstyle')
maxWarnings = 0
ignoreFailures = false
}
spotbugs {
// 固定 4.8.6:其 BCEL 不依赖 commons-lang3 3.18+ 的 getJdkModulePath,
// 避免与 Spring Boot BOM 选定的 commons-lang3 3.14.0 冲突(NoSuchMethodError)。
toolVersion = '4.8.6'
effort = Effort.MAX
reportLevel = Confidence.valueOf('MEDIUM')
excludeFilter = file('gradle/spotbugs/exclude.xml')
ignoreFailures = false
}
tasks.named('spotbugsMain') {
reports {
xml {
required = true
outputLocation = file("${layout.buildDirectory.get().asFile}/reports/spotbugs/main.xml")
}
html {
required = true
outputLocation = file("${layout.buildDirectory.get().asFile}/reports/spotbugs/main.html")
}
}
}
// 测试源码(src/test)仅本地验证、不提交(见 AGENTS.md),不纳入 Checkstyle/SpotBugs
tasks.matching { it.name == 'checkstyleTest' || it.name == 'spotbugsTest' }.configureEach {
enabled = false
}
// ===== 前端构建与打包 =====
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")
}
// 前端代码检查(ESLint),失败则中断构建
tasks.register('frontendLint', Exec) {
group = 'frontend'
description = '运行前端 ESLint 检查(npm run lint)'
dependsOn 'npmInstall'
workingDir frontendDir
commandLine resolveNpm(), 'run', 'lint'
inputs.dir("${frontendDir}/src")
inputs.file("${frontendDir}/eslint.config.js")
inputs.file("${frontendDir}/package.json")
}
// 构建前端产物
tasks.register('frontendBuild', Exec) {
group = 'frontend'
description = '构建前端产物(npm run build)'
dependsOn 'npmInstall', 'frontendLint'
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)
}
// 聚合代码检查任务:后端 Spotless/Checkstyle/SpotBugs(主源码)+ 前端 ESLint
tasks.register('lint') {
group = 'verification'
description = '运行全部代码检查(后端 Spotless、Checkstyle、SpotBugs 与前端 ESLint)'
dependsOn 'spotlessCheck', 'checkstyleMain', 'spotbugsMain', 'frontendLint'
}
// 将前端产物打入可执行 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'
}