为统一前后端代码规范与质量,前端引入 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 保持一致
408 lines
18 KiB
Groovy
408 lines
18 KiB
Groovy
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'
|
||
}
|