Files
ledgerly/build.gradle
T
rosercode 6bb9d7b098 feat: 邮箱账单支持正文链接取件(适配微信账单)
邮箱账单发件人规则新增「取件方式」与「链接匹配规则」,支持从邮件正文提取账单下载链接并抓取文件,解决微信等来源不附附件、账单链接位于正文的取件问题;同时补齐附件文件名超长兜底与前端 i18n 特殊字符转义。

变更内容:
- 新增 EmailBillFetchMode 枚举(ATTACHMENT/BODY_LINK),发件人规则实体、DTO、VO、前端表单与列表同步支持取件方式及链接匹配正则,保存时校验正则合法性。
- 新增 EmailBodyExtractor,基于 jsoup 解析 HTML 与纯文本正文提取候选链接,按出现顺序去重并限制数量。
- 新增 EmailBillLinkDownloader,仅允许 http/https 且拒绝回环/私网地址(防 SSRF),限制 20MB 与 HTML 响应,按 Content-Disposition、URL 路径与文件魔数推断文件名。
- EmailBillProcessor 按取件方式分流处理,未配置链接正则时优先选取受支持扩展名链接、否则依次尝试前若干链接,单条下载失败不影响其余。
- SchemaMigrator 幂等为 t_email_bill_sender_rule 补充 fetch_mode、link_pattern 列,并同步 MySQL/SQLite 建表脚本。
- 默认发件人规则中微信账单改为正文链接取件并补充发件人匹配串,其余来源显式指定附件取件。
- EmailBillStorage 对百分号编码后的文件名做长度截断并保留扩展名,避免超过文件系统 255 字节上限。
- 前端示例与提示文案对 `|`、`@` 使用 vue-i18n 字面量转义,修复渲染异常。
- build.gradle 引入 jsoup 1.18.1 用于正文 HTML 解析。
2026-10-17 12:11:43 +08:00

410 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'
// 邮件正文 HTML 解析:从中提取账单下载链接(部分来源如微信不发附件、账单在正文链接后)
implementation 'org.jsoup:jsoup:1.18.1'
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'
}