feat: 邮箱账单自动拉取

- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口
- 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签
- 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封)
- 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎
- 立即检查通过 SSE 实时推送执行进度
- 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项
- 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行)
- 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用

同时包含:
- 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取
- 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
This commit is contained in:
2026-09-19 17:50:55 +08:00
parent dedf30c74d
commit 9bc858eefc
65 changed files with 5940 additions and 529 deletions
@@ -31,7 +31,7 @@ public class GlobalExceptionHandler {
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("业务异常[{}]: {}", e.getCode(), e.getMessage());
log.debug("业务异常[{}]: {}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@@ -57,7 +57,7 @@ public class GlobalExceptionHandler {
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
// 提取第一个校验错误信息
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.warn("参数校验失败: {}", message);
log.debug("参数校验失败: {}", message);
return Result.error(400, message);
}
@@ -0,0 +1,340 @@
package cn.rosercode.finance.controller;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.Data;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.BillImportRequest;
import cn.rosercode.finance.dto.EmailBillConfigRequest;
import cn.rosercode.finance.dto.EmailBillParseRequest;
import cn.rosercode.finance.dto.EmailBillSenderRuleRequest;
import cn.rosercode.finance.email.EmailBillMonitorManager;
import cn.rosercode.finance.service.EmailBillCheckStreamService;
import cn.rosercode.finance.service.EmailBillConfigService;
import cn.rosercode.finance.service.EmailBillMessageService;
import cn.rosercode.finance.service.EmailBillSenderRuleService;
import cn.rosercode.finance.util.UserContext;
import cn.rosercode.finance.vo.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.EmailBillConfigVO;
import cn.rosercode.finance.vo.EmailBillMessageVO;
import cn.rosercode.finance.vo.EmailBillSenderRuleVO;
/**
* 邮箱账单控制器
*
* <p>提供 IMAP 邮箱配置与发件人识别规则的增删改查、启停与连接测试接口;
* 邮件拉取与账单导入接口在后续版本补充。</p>
*
* @date 2026-09-19 16:41:41
*/
@RestController
@RequestMapping("/api/email-bill")
@Tag(name = "邮箱账单", description = "IMAP 邮箱配置与发件人识别规则相关接口")
public class EmailBillController {
private final EmailBillConfigService configService;
private final EmailBillSenderRuleService senderRuleService;
private final EmailBillMessageService messageService;
private final EmailBillMonitorManager monitorManager;
private final EmailBillCheckStreamService checkStreamService;
public EmailBillController(EmailBillConfigService configService,
EmailBillSenderRuleService senderRuleService,
EmailBillMessageService messageService,
EmailBillMonitorManager monitorManager,
EmailBillCheckStreamService checkStreamService) {
this.configService = configService;
this.senderRuleService = senderRuleService;
this.messageService = messageService;
this.monitorManager = monitorManager;
this.checkStreamService = checkStreamService;
}
/**
* 获取邮箱配置列表
*
* @return 邮箱配置列表
*/
@GetMapping("/configs")
@Operation(summary = "获取邮箱配置列表", description = "获取当前用户的全部 IMAP 邮箱配置,授权码已脱敏")
public Result<List<EmailBillConfigVO>> listConfigs() {
Long userId = UserContext.getUserId();
return Result.success(configService.list(userId));
}
/**
* 创建邮箱配置
*
* @param request 配置请求参数
* @return 新配置ID
*/
@PostMapping("/configs")
@Operation(summary = "创建邮箱配置", description = "为当前用户新增 IMAP 邮箱监听配置")
public Result<Long> createConfig(@RequestBody @Valid EmailBillConfigRequest request) {
Long userId = UserContext.getUserId();
return Result.success(configService.create(userId, request));
}
/**
* 更新邮箱配置
*
* @param id 配置ID
* @param request 配置请求参数
* @return 操作结果
*/
@PutMapping("/configs/{id}")
@Operation(summary = "更新邮箱配置", description = "更新指定邮箱配置,授权码留空表示不修改")
public Result<Void> updateConfig(@Parameter(description = "配置ID") @PathVariable Long id,
@RequestBody @Valid EmailBillConfigRequest request) {
Long userId = UserContext.getUserId();
configService.update(userId, id, request);
return Result.success();
}
/**
* 删除邮箱配置
*
* @param id 配置ID
* @return 操作结果
*/
@DeleteMapping("/configs/{id}")
@Operation(summary = "删除邮箱配置", description = "删除指定邮箱配置及其已拉取的消息记录")
public Result<Void> deleteConfig(@Parameter(description = "配置ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
configService.delete(userId, id);
return Result.success();
}
/**
* 启用或停用邮箱配置
*
* @param id 配置ID
* @param enabled 是否启用
* @return 操作结果
*/
@PutMapping("/configs/{id}/enabled")
@Operation(summary = "启用/停用邮箱配置", description = "切换指定邮箱配置的启用状态,启用后开始监听新邮件")
public Result<Void> toggleConfig(@Parameter(description = "配置ID") @PathVariable Long id,
@RequestBody EnabledRequest enabled) {
Long userId = UserContext.getUserId();
configService.toggleEnabled(userId, id, enabled.getEnabled());
return Result.success();
}
/**
* 测试邮箱连接
*
* @param id 配置ID
* @return 操作结果
*/
@PostMapping("/configs/{id}/test")
@Operation(summary = "测试邮箱连接", description = "使用指定配置建立 IMAP 连接并回写连接状态")
public Result<Void> testConfig(@Parameter(description = "配置ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
configService.testConnection(userId, id);
return Result.success();
}
/**
* 获取发件人规则列表
*
* @return 发件人规则列表
*/
@GetMapping("/sender-rules")
@Operation(summary = "获取发件人规则列表", description = "获取当前用户的全部发件人识别规则,按优先级排序")
public Result<List<EmailBillSenderRuleVO>> listSenderRules() {
Long userId = UserContext.getUserId();
return Result.success(senderRuleService.list(userId));
}
/**
* 创建发件人规则
*
* @param request 规则请求参数
* @return 新规则ID
*/
@PostMapping("/sender-rules")
@Operation(summary = "创建发件人规则", description = "新增发件人识别规则,命中后确定账单类型与默认归类")
public Result<Long> createSenderRule(@RequestBody @Valid EmailBillSenderRuleRequest request) {
Long userId = UserContext.getUserId();
return Result.success(senderRuleService.create(userId, request));
}
/**
* 更新发件人规则
*
* @param id 规则ID
* @param request 规则请求参数
* @return 操作结果
*/
@PutMapping("/sender-rules/{id}")
@Operation(summary = "更新发件人规则", description = "根据规则ID更新发件人识别规则")
public Result<Void> updateSenderRule(@Parameter(description = "规则ID") @PathVariable Long id,
@RequestBody @Valid EmailBillSenderRuleRequest request) {
Long userId = UserContext.getUserId();
senderRuleService.update(userId, id, request);
return Result.success();
}
/**
* 删除发件人规则
*
* @param id 规则ID
* @return 操作结果
*/
@DeleteMapping("/sender-rules/{id}")
@Operation(summary = "删除发件人规则", description = "根据规则ID删除指定发件人识别规则")
public Result<Void> deleteSenderRule(@Parameter(description = "规则ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
senderRuleService.delete(userId, id);
return Result.success();
}
/**
* 启用或停用发件人规则
*
* @param id 规则ID
* @param enabled 是否启用
* @return 操作结果
*/
@PutMapping("/sender-rules/{id}/enabled")
@Operation(summary = "启用/停用发件人规则", description = "切换指定发件人规则的启用状态")
public Result<Void> toggleSenderRule(@Parameter(description = "规则ID") @PathVariable Long id,
@RequestBody EnabledRequest enabled) {
Long userId = UserContext.getUserId();
senderRuleService.toggleEnabled(userId, id, enabled.getEnabled());
return Result.success();
}
/**
* 立即检查邮箱新邮件
*
* @param id 配置ID
* @return 新登记的待解析消息数量
*/
@PostMapping("/configs/{id}/check")
@Operation(summary = "立即检查邮箱", description = "立即连接邮箱并增量拉取新邮件附件")
public Result<Integer> checkConfig(@Parameter(description = "配置ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
configService.getRawConfig(userId, id);
return Result.success(monitorManager.checkNow(id));
}
/**
* 立即检查邮箱(实时进度)
*
* @param id 配置ID
* @return SSE 进度流
*/
@GetMapping("/configs/{id}/check/stream")
@Operation(summary = "立即检查邮箱(实时进度)", description = "以 SSE 实时推送立即检查的执行进度,结束时返回新增待处理条数")
public SseEmitter checkConfigStream(@Parameter(description = "配置ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
return checkStreamService.stream(userId, id);
}
/**
* 分页查询账单消息
*
* @param status 处理状态筛选
* @param configId 邮箱配置ID筛选
* @param page 页码
* @param size 每页大小
* @return 分页结果
*/
@GetMapping("/messages")
@Operation(summary = "查询账单消息", description = "分页查询拉取到的账单消息,可按状态与邮箱筛选")
public Result<PageResult<EmailBillMessageVO>> pageMessages(
@Parameter(description = "处理状态") @RequestParam(required = false) String status,
@Parameter(description = "邮箱配置ID") @RequestParam(required = false) Long configId,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") int page,
@Parameter(description = "每页大小") @RequestParam(defaultValue = "20") int size) {
Long userId = UserContext.getUserId();
return Result.success(messageService.page(userId, status, configId, page, size));
}
/**
* 解析账单附件(不落库)
*
* @param id 消息ID
* @param request 解析请求
* @return 解析预览结果
*/
@PostMapping("/messages/{id}/parse")
@Operation(summary = "解析账单附件", description = "读取本地附件并返回预览数据,可覆盖账单类型与解压密码")
public Result<BillParseResultVO> parseMessage(@Parameter(description = "消息ID") @PathVariable Long id,
@RequestBody(required = false) EmailBillParseRequest request) {
Long userId = UserContext.getUserId();
return Result.success(messageService.parse(userId, id, request));
}
/**
* 确认导入勾选的账单行
*
* @param id 消息ID
* @param request 导入请求
* @return 导入结果统计
*/
@PostMapping("/messages/{id}/import")
@Operation(summary = "导入账单", description = "将用户勾选的账单行写入交易记录,成功后标记消息为已导入")
public Result<BillImportResultVO> importMessage(@Parameter(description = "消息ID") @PathVariable Long id,
@RequestBody @Valid BillImportRequest request) {
Long userId = UserContext.getUserId();
return Result.success(messageService.importBill(userId, id, request));
}
/**
* 忽略账单消息
*
* @param id 消息ID
* @return 操作结果
*/
@PutMapping("/messages/{id}/ignore")
@Operation(summary = "忽略账单消息", description = "将指定消息标记为已忽略")
public Result<Void> ignoreMessage(@Parameter(description = "消息ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
messageService.ignore(userId, id);
return Result.success();
}
/**
* 删除账单消息
*
* @param id 消息ID
* @return 操作结果
*/
@DeleteMapping("/messages/{id}")
@Operation(summary = "删除账单消息", description = "删除指定消息及其本地附件")
public Result<Void> deleteMessage(@Parameter(description = "消息ID") @PathVariable Long id) {
Long userId = UserContext.getUserId();
messageService.delete(userId, id);
return Result.success();
}
/**
* 启用状态请求体
*/
@Data
public static class EnabledRequest {
/** 是否启用 */
private Boolean enabled;
}
}
@@ -8,6 +8,9 @@ import org.mapstruct.ReportingPolicy;
import cn.rosercode.finance.entity.AiUsage;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.entity.HttpLog;
import cn.rosercode.finance.entity.LoginLog;
import cn.rosercode.finance.entity.NotificationChannel;
@@ -18,6 +21,9 @@ import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.User;
import cn.rosercode.finance.vo.AiUsageVO;
import cn.rosercode.finance.vo.CategoryVO;
import cn.rosercode.finance.vo.EmailBillConfigVO;
import cn.rosercode.finance.vo.EmailBillMessageVO;
import cn.rosercode.finance.vo.EmailBillSenderRuleVO;
import cn.rosercode.finance.vo.HttpLogVO;
import cn.rosercode.finance.vo.LoginLogVO;
import cn.rosercode.finance.vo.NotificationChannelVO;
@@ -154,4 +160,38 @@ public interface VoConverter {
@Mapping(target = "categoryIcon", source = "category.icon", defaultValue = "")
@Mapping(target = "tags", ignore = true)
RuleVO toRuleVO(Rule rule, Category category);
/**
* 邮箱账单配置实体转视图对象。
*
* <p>授权码不返回,仅标记是否已配置;默认标签列表由调用方解析填充。</p>
*
* @param config 邮箱配置实体
* @return 邮箱配置视图对象
*/
@Mapping(target = "hasPassword",
expression = "java(config.getPassword() != null && !config.getPassword().isBlank())")
@Mapping(target = "ssl", source = "useSsl")
@Mapping(target = "tagIds", ignore = true)
EmailBillConfigVO toEmailBillConfigVO(EmailBillConfig config);
/**
* 邮箱账单发件人规则实体转视图对象。
*
* <p>发件人匹配串与默认标签列表由调用方解析填充。</p>
*
* @param rule 发件人规则实体
* @return 发件人规则视图对象
*/
@Mapping(target = "senderPatterns", ignore = true)
@Mapping(target = "tagIds", ignore = true)
EmailBillSenderRuleVO toEmailBillSenderRuleVO(EmailBillSenderRule rule);
/**
* 邮箱账单消息实体转视图对象。
*
* @param message 消息实体
* @return 消息视图对象
*/
EmailBillMessageVO toEmailBillMessageVO(EmailBillMessage message);
}
@@ -0,0 +1,57 @@
package cn.rosercode.finance.dto;
import java.util.List;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 邮箱账单配置请求DTO,用于创建或更新 IMAP 邮箱监听配置。
*
* @date 2026-09-19 16:40:37
*/
@Data
public class EmailBillConfigRequest {
/** 配置名称,不能为空 */
@NotBlank(message = "配置名称不能为空")
private String name;
/** IMAP 服务器地址,不能为空 */
@NotBlank(message = "IMAP 服务器地址不能为空")
private String host;
/** IMAP 端口,可选,默认 993 */
private Integer port;
/** 是否使用 SSL,可选,默认启用 */
private Boolean ssl;
/** 邮箱账号,不能为空 */
@NotBlank(message = "邮箱账号不能为空")
private String username;
/** 邮箱授权码,更新时留空表示不修改 */
private String password;
/** 监听的邮件文件夹,可选,默认 INBOX */
private String folder;
/** 是否启用监听,可选,默认关闭 */
private Boolean enabled;
/** 默认支出分类ID,可选 */
private Long expenseCategoryId;
/** 默认收入分类ID,可选 */
private Long incomeCategoryId;
/** 默认标签ID列表,可选 */
private List<Long> tagIds;
/** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封,可选,默认 INCREMENT */
private String fetchScope;
/** 拉取范围数量:DAYS 为天数,COUNT 为封数,可选 */
private Integer fetchLimit;
}
@@ -0,0 +1,20 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 邮箱账单解析请求DTO,用于手动触发待处理附件的解析。
*
* <p>账单类型与解压密码默认取发件人规则,可在请求中覆盖,便于用户手动纠正。</p>
*
* @date 2026-09-19 16:48:34
*/
@Data
public class EmailBillParseRequest {
/** 账单类型,为空时使用发件人规则识别的类型 */
private String billType;
/** 压缩包解压密码,为空时使用发件人规则配置的密码 */
private String password;
}
@@ -0,0 +1,44 @@
package cn.rosercode.finance.dto;
import java.util.List;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 邮箱账单发件人规则请求DTO,用于创建或更新发件人识别规则。
*
* @date 2026-09-19 16:40:39
*/
@Data
public class EmailBillSenderRuleRequest {
/** 规则名称,不能为空 */
@NotBlank(message = "规则名称不能为空")
private String name;
/** 是否启用,可选,默认启用 */
private Boolean enabled;
/** 优先级,数值越小越优先,可选 */
private Integer sortOrder;
/** 发件人匹配串列表,至少一个 */
private List<String> senderPatterns;
/** 命中的账单类型,不能为空 */
@NotBlank(message = "请选择账单类型")
private String billType;
/** 该来源账单压缩包解压密码,可选 */
private String archivePassword;
/** 默认支出分类ID,可选 */
private Long expenseCategoryId;
/** 默认收入分类ID,可选 */
private Long incomeCategoryId;
/** 默认标签ID列表,可选 */
private List<Long> tagIds;
}
@@ -39,4 +39,10 @@ public class StatisticsRangeQuery {
/** 备注关键字,模糊匹配 */
private String remark;
/**
* 统计口径:INCLUDED-仅计入统计(默认)、ALL-全部记录、EXCLUDED-仅不计入统计。
* 为空时按 INCLUDED 处理,兼容旧调用。
*/
private String statsScope;
}
@@ -0,0 +1,29 @@
package cn.rosercode.finance.email;
import lombok.Data;
/**
* 邮件附件中间对象,承载从邮件中提取出的附件文件名与字节内容。
*
* @date 2026-09-19 16:43:43
*/
@Data
public class EmailAttachment {
/** 附件文件名 */
private String fileName;
/** 附件字节内容 */
private byte[] content;
/**
* 构造附件对象。
*
* @param fileName 附件文件名
* @param content 附件字节内容
*/
public EmailAttachment(String fileName, byte[] content) {
this.fileName = fileName;
this.content = content;
}
}
@@ -0,0 +1,139 @@
package cn.rosercode.finance.email;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.List;
import jakarta.mail.MessagingException;
import jakarta.mail.Multipart;
import jakarta.mail.Part;
import jakarta.mail.internet.MimeUtility;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 邮件附件提取器,从 MIME 邮件中递归提取附件文件名与字节内容。
*
* <p>仅处理带文件名且非纯正文的部件,并限制单个附件与整封邮件的总大小,
* 避免超大附件导致内存膨胀。</p>
*
* @date 2026-09-19 16:43:47
*/
@Component
@Slf4j
public class EmailAttachmentExtractor {
/** 单个附件最大字节数(20MB) */
private static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
/** 单封邮件附件总大小上限(50MB) */
private static final long MAX_TOTAL_SIZE = 50L * 1024 * 1024;
/** 单封邮件最多提取的附件数 */
private static final int MAX_ATTACHMENTS = 20;
/**
* 提取邮件中的全部附件。
*
* @param message 邮件消息
* @return 附件列表,无附件时返回空列表
* @throws MessagingException 邮件解析失败
* @throws IOException 附件读取失败
*/
public List<EmailAttachment> extract(Part message) throws MessagingException, IOException {
List<EmailAttachment> attachments = new ArrayList<>();
// 以数组承载累计大小,便于递归传递
long[] total = new long[]{0L};
extractPart(message, attachments, total);
return attachments;
}
/**
* 递归提取单个 MIME 部件中的附件。
*
* @param part 当前部件
* @param attachments 附件收集列表
* @param total 已提取总字节数(单元素数组)
* @throws MessagingException 邮件解析失败
* @throws IOException 附件读取失败
*/
private void extractPart(Part part, List<EmailAttachment> attachments, long[] total)
throws MessagingException, IOException {
if (attachments.size() >= MAX_ATTACHMENTS) {
return;
}
// 多部分内容递归处理
if (part.isMimeType("multipart/*")) {
Multipart multipart = (Multipart) part.getContent();
for (int i = 0; i < multipart.getCount(); i++) {
extractPart(multipart.getBodyPart(i), attachments, total);
}
return;
}
String fileName = decodeFileName(part.getFileName());
if (fileName == null || fileName.isBlank()) {
return;
}
String disposition = part.getDisposition();
// 仅处理明确的附件或内联文件,跳过正文文本
if (disposition != null && !Part.ATTACHMENT.equalsIgnoreCase(disposition)
&& !Part.INLINE.equalsIgnoreCase(disposition)) {
return;
}
byte[] content = readLimited(part.getInputStream());
if (content == null) {
log.warn("跳过超过大小上限的邮件附件:{}", fileName);
return;
}
total[0] += content.length;
if (total[0] > MAX_TOTAL_SIZE) {
log.warn("邮件附件总大小超过上限,停止提取:configId 未知");
return;
}
attachments.add(new EmailAttachment(fileName, content));
}
/**
* 读取附件内容并限制最大字节数。
*
* @param in 输入流
* @return 字节内容,超过上限时返回 null
* @throws IOException 读取失败
*/
private byte[] readLimited(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long total = 0;
int read;
while ((read = in.read(buffer)) != -1) {
total += read;
if (total > MAX_ATTACHMENT_SIZE) {
return null;
}
out.write(buffer, 0, read);
}
return out.toByteArray();
}
/**
* 解码附件文件名(处理 RFC 2047 编码)。
*
* @param fileName 原始文件名
* @return 解码后的文件名,为空时返回 null
*/
private String decodeFileName(String fileName) {
if (fileName == null) {
return null;
}
try {
return MimeUtility.decodeText(fileName);
} catch (UnsupportedEncodingException e) {
return fileName;
}
}
}
@@ -0,0 +1,220 @@
package cn.rosercode.finance.email;
import jakarta.mail.Folder;
import jakarta.mail.MessagingException;
import jakarta.mail.Store;
import jakarta.mail.event.MessageCountAdapter;
import jakarta.mail.event.MessageCountEvent;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.angus.mail.imap.IMAPFolder;
import org.eclipse.angus.mail.imap.IMAPStore;
import cn.rosercode.finance.entity.EmailBillConfig;
/**
* 邮箱账单监听线程,为单个邮箱配置维持 IMAP IDLE 长连接。
*
* <p>服务器支持 IDLE 时实时响应新邮件;不支持时降级为定时轮询。连接断开后
* 按指数退避重连,收到新邮件或轮询到变更时交由 {@link EmailBillPuller} 增量拉取。</p>
*
* @date 2026-09-19 16:44:45
*/
@Slf4j
public class EmailBillMonitor implements Runnable {
/** 初始重连间隔(毫秒) */
private static final long BASE_RECONNECT_DELAY_MS = 5000L;
/** 最大重连间隔(毫秒) */
private static final long MAX_RECONNECT_DELAY_MS = 300000L;
/** 降级轮询间隔(毫秒) */
private static final long POLL_INTERVAL_MS = 60000L;
private final EmailBillConfig config;
private final EmailImapConnector imapConnector;
private final EmailBillPuller puller;
/** 监听是否继续运行 */
private volatile boolean running = true;
private volatile Store store;
private volatile Folder folder;
private volatile IMAPFolder imapFolder;
/**
* 构造监听线程。
*
* @param config 邮箱配置
* @param imapConnector IMAP 连接器
* @param puller 拉取器
*/
public EmailBillMonitor(EmailBillConfig config,
EmailImapConnector imapConnector,
EmailBillPuller puller) {
this.config = config;
this.imapConnector = imapConnector;
this.puller = puller;
}
/**
* 获取配置ID。
*
* @return 配置ID
*/
public Long getConfigId() {
return config.getId();
}
/**
* 监听主循环:连接、IDLE 或轮询、断线重连,直至被停止。
*/
@Override
public void run() {
long delay = BASE_RECONNECT_DELAY_MS;
while (running) {
try {
listen();
// listen 正常返回表示已被停止
break;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (MessagingException e) {
if (!running) {
break;
}
log.warn("邮箱账单监听断开,{}ms 后重连: configId={}, error={}",
delay, config.getId(), e.getMessage());
sleep(delay);
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
} catch (RuntimeException e) {
if (!running) {
break;
}
log.error("邮箱账单监听异常,{}ms 后重连: configId={}", delay, config.getId(), e);
sleep(delay);
delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
}
}
closeResources();
log.info("邮箱账单监听已停止: configId={}", config.getId());
}
/**
* 建立连接并进入 IDLE 或轮询循环。
*
* @throws MessagingException 连接或监听失败
* @throws InterruptedException 线程被中断
*/
private void listen() throws MessagingException, InterruptedException {
Store localStore = imapConnector.connect(config);
Folder localFolder = localStore.getFolder(config.getFolder());
localFolder.open(Folder.READ_ONLY);
this.store = localStore;
this.folder = localFolder;
// 连接建立后先拉取一次,避免错过上次断开期间的邮件
safePull();
boolean idleSupported = localStore instanceof IMAPStore imapStore && imapStore.hasCapability("IDLE");
if (idleSupported && localFolder instanceof IMAPFolder imap) {
this.imapFolder = imap;
localFolder.addMessageCountListener(new MessageCountAdapter() {
@Override
public void messagesAdded(MessageCountEvent event) {
safePull();
}
});
log.info("邮箱账单监听已启动(IDLE): configId={}, folder={}", config.getId(), config.getFolder());
while (running) {
imap.idle();
}
} else {
log.info("邮箱账单监听降级为轮询: configId={}, folder={}", config.getId(), config.getFolder());
while (running) {
Thread.sleep(POLL_INTERVAL_MS);
if (running) {
safePull();
}
}
}
}
/**
* 执行一次拉取并吞掉异常,避免中断监听循环。
*/
private void safePull() {
try {
puller.pull(config);
} catch (RuntimeException e) {
log.warn("邮箱账单拉取失败: configId={}, error={}", config.getId(), e.getMessage());
}
}
/**
* 停止监听:异步请求终止 IDLE 并关闭连接。
*
* <p>连接与 IDLE 的关闭可能阻塞,故在独立守护线程中执行,避免拖慢应用关闭或配置刷新。</p>
*/
public void stop() {
running = false;
Thread closer = new Thread(this::closeSafely, "email-bill-monitor-stop-" + config.getId());
closer.setDaemon(true);
closer.start();
}
/**
* 终止 IDLE 并关闭连接,异常时忽略。
*/
private void closeSafely() {
IMAPFolder local = imapFolder;
if (local != null && local.isOpen()) {
try {
// 主动结束 IDLE,使阻塞中的 idle() 返回
local.idle(false);
} catch (MessagingException ignored) {
// 未处于 IDLE 时可能抛异常,忽略即可
}
}
closeResources();
}
/**
* 关闭文件夹与连接。
*/
private void closeResources() {
Folder localFolder = folder;
Store localStore = store;
folder = null;
store = null;
imapFolder = null;
if (localFolder != null && localFolder.isOpen()) {
try {
localFolder.close(false);
} catch (MessagingException ignored) {
// 关闭失败不影响停止流程
}
}
if (localStore != null && localStore.isConnected()) {
try {
localStore.close();
} catch (MessagingException ignored) {
// 关闭失败不影响停止流程
}
}
}
/**
* 休眠指定毫秒,被中断时保留中断标记。
*
* @param millis 休眠毫秒数
*/
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,176 @@
package cn.rosercode.finance.email;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
/**
* 邮箱账单监听管理器,负责各邮箱配置监听线程的生命周期。
*
* <p>应用启动与定时同步时加载所有已启用的配置并启动监听,配置变更时按需启停;
* 提供手动立即检查入口,并在关闭时优雅停止全部线程。</p>
*
* @date 2026-09-19 16:44:58
*/
@Component
@Slf4j
public class EmailBillMonitorManager {
/** 同时监听的邮箱配置数量上限 */
private static final int MAX_MONITORS = 10;
/** 监听线程命名计数器 */
private static final AtomicLong THREAD_COUNTER = new AtomicLong();
private final EmailBillConfigMapper configMapper;
private final EmailImapConnector imapConnector;
private final EmailBillPuller puller;
private final ConcurrentHashMap<Long, EmailBillMonitor> monitors = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Future<?>> futures = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "email-bill-monitor-" + THREAD_COUNTER.incrementAndGet());
thread.setDaemon(true);
return thread;
});
public EmailBillMonitorManager(EmailBillConfigMapper configMapper,
EmailImapConnector imapConnector,
EmailBillPuller puller) {
this.configMapper = configMapper;
this.imapConnector = imapConnector;
this.puller = puller;
}
/**
* 应用启动时尝试加载并启动监听;数据库未就绪时忽略,交由定时同步补启动。
*/
@PostConstruct
public void init() {
try {
sync();
} catch (Exception e) {
log.debug("启动邮箱账单监听失败,等待定时同步:{}", e.getMessage());
}
}
/**
* 应用关闭时停止全部监听线程。
*/
@PreDestroy
public void destroy() {
int count = monitors.size();
for (Long configId : new ArrayList<>(monitors.keySet())) {
stopMonitor(configId);
}
executor.shutdownNow();
log.info("邮箱账单监听管理器已关闭,已停止 {} 个监听", count);
}
/**
* 同步监听线程:为新增的启用配置启动监听,停止已停用或删除的监听。
*/
public void sync() {
List<EmailBillConfig> enabledConfigs;
try {
enabledConfigs = configMapper.selectList(new LambdaQueryWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getEnabled, true)
.orderByAsc(EmailBillConfig::getId));
} catch (Exception e) {
log.debug("跳过邮箱账单监听同步,读取配置失败:{}", e.getMessage());
return;
}
Set<Long> enabledIds = enabledConfigs.stream()
.map(EmailBillConfig::getId)
.collect(Collectors.toSet());
for (EmailBillConfig config : enabledConfigs) {
if (!monitors.containsKey(config.getId())) {
startMonitor(config);
}
}
for (Long configId : new ArrayList<>(monitors.keySet())) {
if (!enabledIds.contains(configId)) {
stopMonitor(configId);
}
}
}
/**
* 按最新配置刷新单个监听:启用则重启,停用或删除则停止。
*
* @param configId 邮箱配置ID
*/
public void refresh(Long configId) {
EmailBillConfig config = configMapper.selectById(configId);
stopMonitor(configId);
if (config != null && Boolean.TRUE.equals(config.getEnabled())) {
startMonitor(config);
}
}
/**
* 立即执行一次拉取(手动检查)。
*
* @param configId 邮箱配置ID
* @return 新登记的待解析消息数量
*/
public int checkNow(Long configId) {
EmailBillConfig config = configMapper.selectById(configId);
if (config == null) {
throw new BusinessException("邮箱配置不存在");
}
return puller.pull(config);
}
/**
* 启动单个监听线程。
*
* @param config 邮箱配置
*/
private synchronized void startMonitor(EmailBillConfig config) {
if (monitors.containsKey(config.getId())) {
return;
}
if (monitors.size() >= MAX_MONITORS) {
log.warn("邮箱账单监听数量已达上限 {},跳过配置: configId={}", MAX_MONITORS, config.getId());
return;
}
EmailBillMonitor monitor = new EmailBillMonitor(config, imapConnector, puller);
monitors.put(config.getId(), monitor);
futures.put(config.getId(), executor.submit(monitor));
log.info("启动邮箱账单监听: configId={}, host={}", config.getId(), config.getHost());
}
/**
* 停止单个监听线程。
*
* @param configId 邮箱配置ID
*/
private void stopMonitor(Long configId) {
EmailBillMonitor monitor = monitors.remove(configId);
Future<?> future = futures.remove(configId);
if (monitor != null) {
monitor.stop();
}
if (future != null) {
future.cancel(true);
}
}
}
@@ -0,0 +1,263 @@
package cn.rosercode.finance.email;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.enums.EmailBillStatus;
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
import cn.rosercode.finance.service.EmailBillSenderRuleService;
/**
* 邮箱账单处理器,将命中的邮件附件保存到本地并登记为待解析消息。
*
* <p>仅处理发件人命中规则且扩展名受支持的附件;处理阶段不解析账单内容,
* 解析与入库由用户在前端手动触发。</p>
*
* @date 2026-09-19 16:44:05
*/
@Component
@Slf4j
public class EmailBillProcessor {
/** 支持的账单附件扩展名 */
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(".csv", ".xlsx", ".xls", ".pdf", ".zip");
private final EmailBillMessageMapper messageMapper;
private final EmailBillSenderRuleService senderRuleService;
private final EmailBillSenderRuleMatcher senderRuleMatcher;
private final EmailAttachmentExtractor attachmentExtractor;
private final EmailBillStorage storage;
public EmailBillProcessor(EmailBillMessageMapper messageMapper,
EmailBillSenderRuleService senderRuleService,
EmailBillSenderRuleMatcher senderRuleMatcher,
EmailAttachmentExtractor attachmentExtractor,
EmailBillStorage storage) {
this.messageMapper = messageMapper;
this.senderRuleService = senderRuleService;
this.senderRuleMatcher = senderRuleMatcher;
this.attachmentExtractor = attachmentExtractor;
this.storage = storage;
}
/**
* 处理单封邮件:匹配发件人规则,保存附件并登记待解析消息。
*
* @param config 邮箱配置
* @param message 邮件消息
* @param uid 邮件 UID
* @return 新登记的消息数量
*/
public int process(EmailBillConfig config, Message message, long uid) {
return process(config, message, uid, line -> {
});
}
/**
* 处理单封邮件,并通过 {@code progress} 上报匹配与保存进度。
*
* @param config 邮箱配置
* @param message 邮件消息
* @param uid 邮件 UID
* @param progress 进度回调
* @return 新登记的消息数量
*/
public int process(EmailBillConfig config, Message message, long uid, Consumer<String> progress) {
String from = extractFrom(message);
List<EmailBillSenderRule> rules = senderRuleService.listEnabledRules(config.getUserId());
EmailBillSenderRule rule = senderRuleMatcher.match(from, rules);
if (rule == null) {
log.debug("邮件发件人未命中发件人规则,已忽略: configId={}, uid={}, from={}",
config.getId(), uid, from);
progress.accept(" → 发件人未命中规则,忽略");
return 0;
}
progress.accept(" → 命中规则「" + rule.getName() + "」,账单类型 " + rule.getBillType());
List<EmailAttachment> attachments;
try {
attachments = attachmentExtractor.extract(message);
} catch (MessagingException | IOException e) {
log.warn("提取邮件附件失败: configId={}, uid={}, error={}", config.getId(), uid, e.getMessage());
progress.accept(" → 提取附件失败:" + e.getMessage());
return 0;
}
int saved = 0;
for (int i = 0; i < attachments.size(); i++) {
EmailAttachment attachment = attachments.get(i);
if (!isSupported(attachment.getFileName()) || exists(config.getId(), uid, i)) {
continue;
}
String path = storage.save(config.getId(), uid, i, attachment.getFileName(), attachment.getContent());
EmailBillMessage entity = buildMessage(config, message, uid, i, from, rule, attachment, path);
messageMapper.insert(entity);
saved++;
}
if (saved > 0) {
log.info("邮箱账单附件已保存: configId={}, uid={}, from={}, billType={}, 附件数={}",
config.getId(), uid, from, rule.getBillType(), saved);
}
progress.accept(" → 保存 " + saved + " 个附件");
return saved;
}
/**
* 构造待解析消息实体。
*
* @param config 邮箱配置
* @param message 邮件消息
* @param uid 邮件 UID
* @param index 附件序号
* @param from 发件人地址
* @param rule 命中的发件人规则
* @param attachment 附件
* @param path 附件本地相对路径
* @return 消息实体
*/
private EmailBillMessage buildMessage(EmailBillConfig config, Message message, long uid, int index,
String from, EmailBillSenderRule rule, EmailAttachment attachment,
String path) {
EmailBillMessage entity = new EmailBillMessage();
entity.setConfigId(config.getId());
entity.setUserId(config.getUserId());
entity.setUid(uid);
entity.setMessageId(messageId(message));
entity.setSubject(truncate(safeSubject(message), 500));
entity.setFromAddress(truncate(from, 255));
entity.setReceivedAt(receivedAt(message));
entity.setAttachmentIndex(index);
entity.setAttachmentName(truncate(attachment.getFileName(), 255));
entity.setAttachmentPath(path);
entity.setSenderRuleId(rule.getId());
entity.setBillType(rule.getBillType());
entity.setStatus(EmailBillStatus.PULLED.name());
return entity;
}
/**
* 判断该邮件附件是否已登记,避免重复处理。
*
* @param configId 邮箱配置ID
* @param uid 邮件 UID
* @param index 附件序号
* @return 已存在返回 true
*/
private boolean exists(Long configId, long uid, int index) {
return messageMapper.selectCount(new LambdaQueryWrapper<EmailBillMessage>()
.eq(EmailBillMessage::getConfigId, configId)
.eq(EmailBillMessage::getUid, uid)
.eq(EmailBillMessage::getAttachmentIndex, index)) > 0;
}
/**
* 提取邮件发件人地址。
*
* @param message 邮件消息
* @return 发件人字符串,读取失败时返回 null
*/
private String extractFrom(Message message) {
try {
Address[] from = message.getFrom();
if (from == null || from.length == 0) {
return null;
}
return from[0].toString();
} catch (MessagingException e) {
return null;
}
}
/**
* 读取邮件 Message-ID。
*
* @param message 邮件消息
* @return Message-ID,读取失败或非 MimeMessage 时返回 null
*/
private String messageId(Message message) {
if (message instanceof MimeMessage mimeMessage) {
try {
return truncate(mimeMessage.getMessageID(), 255);
} catch (MessagingException e) {
return null;
}
}
return null;
}
/**
* 读取邮件主题。
*
* @param message 邮件消息
* @return 主题,读取失败时返回 null
*/
private String safeSubject(Message message) {
try {
return message.getSubject();
} catch (MessagingException e) {
return null;
}
}
/**
* 读取邮件接收时间,缺失时回退当前时间。
*
* @param message 邮件消息
* @return 接收时间
*/
private LocalDateTime receivedAt(Message message) {
try {
Date date = message.getReceivedDate();
if (date == null) {
return LocalDateTime.now();
}
return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
} catch (MessagingException e) {
return LocalDateTime.now();
}
}
/**
* 判断附件扩展名是否受支持。
*
* @param fileName 附件文件名
* @return 受支持返回 true
*/
private boolean isSupported(String fileName) {
if (fileName == null) {
return false;
}
String lower = fileName.toLowerCase();
return SUPPORTED_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
/**
* 截断字符串以适配数据库字段长度。
*
* @param value 原字符串
* @param maxLength 最大长度
* @return 截断后的字符串
*/
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
}
@@ -0,0 +1,341 @@
package cn.rosercode.finance.email;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.mail.Folder;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Store;
import jakarta.mail.UIDFolder;
import jakarta.mail.search.ComparisonTerm;
import jakarta.mail.search.ReceivedDateTerm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
/**
* 邮箱账单拉取器,按配置的拉取范围执行一次拉取(连接、获取目标邮件、交给处理器)。
*
* <p>支持三种范围:仅新邮件(增量)、最近 N 天、最近 N 封;IDLE 实时监听、定时兜底与手动检查
* 均复用本类。同一配置的拉取通过锁串行化,配合消息唯一索引避免并发重复处理。</p>
*
* @date 2026-09-19 16:44:37
*/
@Component
@Slf4j
public class EmailBillPuller {
/** 连接状态:已连接 */
private static final String STATUS_CONNECTED = "CONNECTED";
/** 连接状态:异常 */
private static final String STATUS_ERROR = "ERROR";
/** 失败原因最大长度 */
private static final int ERROR_LIMIT = 500;
/** 拉取范围:仅新邮件 */
private static final String SCOPE_INCREMENT = "INCREMENT";
/** 拉取范围:最近 N 天 */
private static final String SCOPE_DAYS = "DAYS";
/** 拉取范围:最近 N 封 */
private static final String SCOPE_COUNT = "COUNT";
/** 最近 N 天模式的默认天数 */
private static final int DEFAULT_DAYS = 1;
/** 最近 N 封模式的默认封数 */
private static final int DEFAULT_COUNT = 20;
private final EmailImapConnector imapConnector;
private final EmailBillProcessor processor;
private final EmailBillConfigMapper configMapper;
/** 每个配置一把锁,串行化同一配置的拉取 */
private final ConcurrentHashMap<Long, Object> locks = new ConcurrentHashMap<>();
public EmailBillPuller(EmailImapConnector imapConnector,
EmailBillProcessor processor,
EmailBillConfigMapper configMapper) {
this.imapConnector = imapConnector;
this.processor = processor;
this.configMapper = configMapper;
}
/**
* 执行一次拉取。
*
* <p>增量模式下首次以当前最大 UID 为基线、不回溯历史;最近 N 天/最近 N 封模式每次按范围扫描,
* 依赖消息唯一索引去重。</p>
*
* @param config 邮箱配置
* @return 新登记的待解析消息数量
*/
public int pull(EmailBillConfig config) {
return pull(config, message -> {
});
}
/**
* 执行一次拉取,并通过 {@code progress} 实时上报执行进度。
*
* <p>增量模式下首次以当前最大 UID 为基线、不回溯历史;最近 N 天/最近 N 封模式每次按范围扫描,
* 依赖消息唯一索引去重。</p>
*
* @param config 邮箱配置
* @param progress 进度回调,逐行接收中文进度描述
* @return 新登记的待解析消息数量
*/
public int pull(EmailBillConfig config, Consumer<String> progress) {
Object lock = locks.computeIfAbsent(config.getId(), key -> new Object());
synchronized (lock) {
Store store = null;
Folder folder = null;
try {
progress.accept("连接邮箱 " + config.getHost() + ":" + config.getPort()
+ (Boolean.FALSE.equals(config.getUseSsl()) ? "(明文)" : "(SSL)"));
store = imapConnector.connect(config);
folder = store.getFolder(config.getFolder());
folder.open(Folder.READ_ONLY);
progress.accept("已连接,打开文件夹 " + config.getFolder());
UIDFolder uidFolder = (UIDFolder) folder;
String scope = resolveScope(config);
progress.accept("拉取范围:" + scopeLabel(scope));
long baseUid = initLastUid(config, folder, uidFolder);
Message[] messages = fetchMessages(config, folder, uidFolder, baseUid, scope);
progress.accept("检索到 " + messages.length + " 封邮件");
int processed = 0;
long maxUid = baseUid;
int index = 0;
for (Message message : messages) {
long uid = uidFolder.getUID(message);
if (uid <= 0 || (SCOPE_INCREMENT.equals(scope) && uid <= baseUid)) {
continue;
}
index++;
progress.accept("(" + index + "/" + messages.length + ") uid=" + uid
+ " " + safeSubject(message));
try {
processed += processor.process(config, message, uid, progress);
} catch (RuntimeException e) {
// 单封邮件处理失败不影响其余邮件
log.warn("处理邮件失败: configId={}, uid={}, error={}", config.getId(), uid, e.getMessage());
progress.accept(" → 处理失败:" + e.getMessage());
}
maxUid = Math.max(maxUid, uid);
}
if (maxUid > baseUid) {
updateLastUid(config, maxUid);
}
updateStatus(config, STATUS_CONNECTED, null, true);
progress.accept("完成,新增 " + processed + " 条待处理账单");
return processed;
} catch (MessagingException e) {
updateStatus(config, STATUS_ERROR, e.getMessage(), false);
progress.accept("失败:" + e.getMessage());
throw new BusinessException("拉取邮件失败:" + e.getMessage());
} finally {
closeQuietly(folder);
closeQuietly(store);
}
}
}
/**
* 将拉取范围编码转换为中文描述。
*
* @param scope 拉取范围编码
* @return 中文描述
*/
private String scopeLabel(String scope) {
if (SCOPE_DAYS.equals(scope)) {
return "最近 N 天";
}
if (SCOPE_COUNT.equals(scope)) {
return "最近 N 封";
}
return "仅新邮件";
}
/**
* 读取邮件主题,失败时返回空串。
*
* @param message 邮件消息
* @return 主题
*/
private String safeSubject(Message message) {
try {
String subject = message.getSubject();
return subject == null ? "" : subject;
} catch (MessagingException e) {
return "";
}
}
/**
* 按配置的拉取范围获取待处理邮件。
*
* @param config 邮箱配置
* @param folder 已打开的文件夹
* @param uidFolder UID 文件夹
* @param baseUid 增量模式下的基线 UID
* @param scope 拉取范围
* @return 待处理邮件数组
* @throws MessagingException 搜索失败
*/
private Message[] fetchMessages(EmailBillConfig config, Folder folder, UIDFolder uidFolder,
long baseUid, String scope) throws MessagingException {
if (SCOPE_DAYS.equals(scope)) {
int days = config.getFetchLimit() != null && config.getFetchLimit() > 0
? config.getFetchLimit() : DEFAULT_DAYS;
// 含当天,往前推 days-1 天的 0 点
Date since = Date.from(LocalDate.now().minusDays(days - 1L)
.atStartOfDay(ZoneId.systemDefault()).toInstant());
return folder.search(new ReceivedDateTerm(ComparisonTerm.GE, since));
}
if (SCOPE_COUNT.equals(scope)) {
int count = config.getFetchLimit() != null && config.getFetchLimit() > 0
? config.getFetchLimit() : DEFAULT_COUNT;
int total = folder.getMessageCount();
if (total <= 0) {
return new Message[0];
}
return folder.getMessages(Math.max(1, total - count + 1), total);
}
return uidFolder.getMessagesByUID(baseUid + 1, UIDFolder.LASTUID);
}
/**
* 解析配置的拉取范围,非法值回退为增量模式。
*
* @param config 邮箱配置
* @return INCREMENT / DAYS / COUNT
*/
private String resolveScope(EmailBillConfig config) {
String scope = config.getFetchScope();
if (scope == null) {
return SCOPE_INCREMENT;
}
String upper = scope.trim().toUpperCase();
if (SCOPE_DAYS.equals(upper) || SCOPE_COUNT.equals(upper)) {
return upper;
}
return SCOPE_INCREMENT;
}
/**
* 初始化拉取基线 UID:已存在则直接使用,否则取当前最大 UID 并持久化。
*
* @param config 邮箱配置
* @param folder 已打开的文件夹
* @param uidFolder UID 文件夹
* @return 基线 UID
* @throws MessagingException 读取失败
*/
private long initLastUid(EmailBillConfig config, Folder folder, UIDFolder uidFolder) throws MessagingException {
if (config.getLastUid() != null) {
return config.getLastUid();
}
long maxUid = 0L;
int count = folder.getMessageCount();
if (count > 0) {
Message last = folder.getMessage(count);
maxUid = uidFolder.getUID(last);
}
updateLastUid(config, maxUid);
log.info("初始化邮箱账单拉取基线: configId={}, baseUid={}", config.getId(), maxUid);
return maxUid;
}
/**
* 更新已处理的最大 UID。
*
* @param config 邮箱配置
* @param uid 最大 UID
*/
private void updateLastUid(EmailBillConfig config, long uid) {
config.setLastUid(uid);
configMapper.update(null, new LambdaUpdateWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getId, config.getId())
.set(EmailBillConfig::getLastUid, uid));
}
/**
* 回写连接状态与最近连接时间。
*
* @param config 邮箱配置
* @param status 连接状态
* @param error 失败原因,可为空
* @param touchConnected 是否刷新最近连接时间
*/
private void updateStatus(EmailBillConfig config, String status, String error, boolean touchConnected) {
config.setStatus(status);
config.setLastError(error);
LambdaUpdateWrapper<EmailBillConfig> update = new LambdaUpdateWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getId, config.getId())
.set(EmailBillConfig::getStatus, status)
.set(EmailBillConfig::getLastError, truncate(error));
if (touchConnected) {
config.setLastConnectedAt(LocalDateTime.now());
update.set(EmailBillConfig::getLastConnectedAt, config.getLastConnectedAt());
}
configMapper.update(null, update);
}
/**
* 静默关闭邮件文件夹。
*
* @param folder 文件夹,可为 null
*/
private void closeQuietly(Folder folder) {
if (folder == null || !folder.isOpen()) {
return;
}
try {
folder.close(false);
} catch (MessagingException ignored) {
// 关闭失败不影响拉取结果
}
}
/**
* 静默关闭邮件连接。
*
* @param store 连接,可为 null
*/
private void closeQuietly(Store store) {
if (store == null) {
return;
}
try {
store.close();
} catch (MessagingException ignored) {
// 关闭失败不影响拉取结果
}
}
/**
* 截断过长的失败原因。
*
* @param message 原文
* @return 截断后的文本
*/
private String truncate(String message) {
if (message == null) {
return null;
}
return message.length() <= ERROR_LIMIT ? message : message.substring(0, ERROR_LIMIT);
}
}
@@ -0,0 +1,72 @@
package cn.rosercode.finance.email;
import java.util.List;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.entity.EmailBillSenderRule;
/**
* 发件人规则匹配器,按邮件发件人地址匹配已启用的发件人识别规则。
*
* <p>规则中的匹配串与发件人地址做包含匹配(忽略大小写),可同时覆盖完整地址、
* {@code @域名} 与关键字;按规则传入顺序(已按优先级排序)取首个命中。</p>
*
* @date 2026-09-19 16:44:01
*/
@Component
@Slf4j
public class EmailBillSenderRuleMatcher {
private final ObjectMapper objectMapper;
public EmailBillSenderRuleMatcher(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 匹配发件人所属的规则。
*
* @param fromAddress 发件人地址
* @param rules 已启用的规则列表,需按优先级排序
* @return 命中的规则,未命中时返回 null
*/
public EmailBillSenderRule match(String fromAddress, List<EmailBillSenderRule> rules) {
if (!StringUtils.hasText(fromAddress) || rules == null || rules.isEmpty()) {
return null;
}
String from = fromAddress.toLowerCase();
for (EmailBillSenderRule rule : rules) {
for (String pattern : parsePatterns(rule.getSenderPatterns())) {
if (from.contains(pattern.toLowerCase())) {
return rule;
}
}
}
return null;
}
/**
* 解析规则中的发件人匹配串列表。
*
* @param json 匹配串 JSON 数组
* @return 匹配串列表,解析失败或为空时返回空列表
*/
public List<String> parsePatterns(String json) {
if (!StringUtils.hasText(json)) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {
});
} catch (JsonProcessingException e) {
log.warn("解析发件人匹配串失败:{}", e.getMessage());
return List.of();
}
}
}
@@ -0,0 +1,108 @@
package cn.rosercode.finance.email;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.common.BusinessException;
/**
* 邮箱账单附件本地存储。
*
* <p>附件保存在工作目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}},
* 数据库仅记录相对路径;导入或忽略后不自动删除,便于追溯与重新解析。</p>
*
* @date 2026-09-19 16:43:56
*/
@Component
@Slf4j
public class EmailBillStorage {
/** 附件存储根目录(相对工作目录) */
private static final Path ROOT = Paths.get("data", "email-bill");
/**
* 保存附件到本地,返回相对存储根目录的路径。
*
* @param configId 邮箱配置ID
* @param uid 邮件 UID
* @param index 附件序号
* @param fileName 附件文件名
* @param content 附件字节内容
* @return 相对存储根目录的路径
*/
public String save(Long configId, long uid, int index, String fileName, byte[] content) {
String relative = configId + "/" + uid + "/" + index + "_" + sanitize(fileName);
Path target = ROOT.resolve(relative);
try {
Files.createDirectories(target.getParent());
Files.write(target, content);
return relative;
} catch (IOException e) {
throw new BusinessException("保存邮件附件失败:" + e.getMessage());
}
}
/**
* 读取附件内容。
*
* @param relativePath 相对存储根目录的路径
* @return 附件字节内容
*/
public byte[] read(String relativePath) {
Path target = resolveSafely(relativePath);
try {
return Files.readAllBytes(target);
} catch (IOException e) {
throw new BusinessException("读取邮件附件失败:" + e.getMessage());
}
}
/**
* 删除附件文件,文件不存在时忽略。
*
* @param relativePath 相对存储根目录的路径
*/
public void delete(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return;
}
try {
Files.deleteIfExists(resolveSafely(relativePath));
} catch (IOException e) {
log.warn("删除邮件附件失败:{}", e.getMessage());
}
}
/**
* 解析相对路径并防止路径穿越。
*
* @param relativePath 相对存储根目录的路径
* @return 绝对路径
*/
private Path resolveSafely(String relativePath) {
Path target = ROOT.resolve(relativePath).normalize();
if (!target.startsWith(ROOT.normalize())) {
throw new BusinessException("非法的附件路径");
}
return target;
}
/**
* 清洗附件文件名,去除路径分隔符与非法字符。
*
* @param fileName 原始文件名
* @return 安全的文件名
*/
private String sanitize(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "attachment";
}
String name = Paths.get(fileName).getFileName().toString();
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
}
}
@@ -0,0 +1,53 @@
package cn.rosercode.finance.email;
import java.util.Properties;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.Store;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.entity.EmailBillConfig;
/**
* IMAP 连接器,负责按邮箱配置建立 IMAP/IMAPS 连接。
*
* <p>QQ、163 等邮箱通常使用 993 端口并开启 SSL;超时时间设置较短,
* 避免监听线程在断网时长时间阻塞。</p>
*
* @date 2026-09-19 16:40:47
*/
@Component
public class EmailImapConnector {
/** 连接超时时间(毫秒) */
private static final String CONNECT_TIMEOUT = "15000";
/** 读取超时时间(毫秒) */
private static final String READ_TIMEOUT = "30000";
/**
* 根据配置建立 IMAP 连接。
*
* @param config 邮箱配置
* @return 已连接的 {@link Store},调用方负责关闭
* @throws MessagingException 连接失败
*/
public Store connect(EmailBillConfig config) throws MessagingException {
boolean ssl = config.getUseSsl() == null || config.getUseSsl();
String protocol = ssl ? "imaps" : "imap";
Properties props = new Properties();
props.put("mail.store.protocol", protocol);
props.put("mail." + protocol + ".connectiontimeout", CONNECT_TIMEOUT);
props.put("mail." + protocol + ".timeout", READ_TIMEOUT);
if (ssl) {
props.put("mail.imaps.ssl.enable", "true");
}
Session session = Session.getInstance(props);
Store store = session.getStore(protocol);
store.connect(config.getHost(), config.getPort(), config.getUsername(), config.getPassword());
return store;
}
}
@@ -20,6 +20,12 @@ public class AiConfig {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
@@ -34,10 +40,4 @@ public class AiConfig {
/** 使用的模型名称 */
private String model;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -20,6 +20,9 @@ public class AiUsage {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 所属用户ID */
private Long userId;
@@ -52,7 +55,4 @@ public class AiUsage {
/** 调用耗时(毫秒) */
private Long durationMs;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -18,6 +18,9 @@ public class Category {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 所属用户ID */
private Long userId;
@@ -32,7 +35,4 @@ public class Category {
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,87 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 邮箱账单配置实体类,对应数据库表 t_email_bill_config。
*
* <p>保存用于自动拉取账单附件的 IMAP 邮箱连接信息,以及默认分类与标签;
* 每个配置对应一个后台监听线程。</p>
*
* @date 2026-09-19 16:40:20
*/
@Data
@TableName("t_email_bill_config")
public class EmailBillConfig {
/** 配置ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
/** 配置名称,便于用户识别 */
private String name;
/** IMAP 服务器地址 */
private String host;
/** IMAP 端口 */
private Integer port;
/** 是否使用 SSL:true-是,false-否(列名 use_ssl,避免与 MySQL 关键字冲突) */
@TableField("use_ssl")
private Boolean useSsl;
/** 邮箱账号 */
private String username;
/** 邮箱授权码(明文存储,前端脱敏返回) */
private String password;
/** 监听的邮件文件夹 */
private String folder;
/** 是否启用监听:true-启用,false-关闭 */
private Boolean enabled;
/** 连接状态:DISCONNECTED-未连接,CONNECTED-已连接,POLLING-轮询中,ERROR-异常 */
private String status;
/** 最近一次连接或拉取失败原因 */
private String lastError;
/** 最近一次成功连接时间 */
private LocalDateTime lastConnectedAt;
/** 已处理的最大邮件 UID,用于增量拉取 */
private Long lastUid;
/** 默认支出分类ID,可为空 */
private Long expenseCategoryId;
/** 默认收入分类ID,可为空 */
private Long incomeCategoryId;
/** 默认标签ID列表(JSON数组) */
private String tagIds;
/** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封 */
private String fetchScope;
/** 拉取范围数量:DAYS 为天数,COUNT 为封数 */
private Integer fetchLimit;
}
@@ -0,0 +1,73 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 邮箱账单消息实体类,对应数据库表 t_email_bill_message。
*
* <p>记录从邮箱拉取到的账单附件(仅保存附件到本地并登记元信息),
* 解析与入库均由用户在前端手动触发,故不持久化解析结果。</p>
*
* @date 2026-09-19 16:40:26
*/
@Data
@TableName("t_email_bill_message")
public class EmailBillMessage {
/** 消息ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 邮箱配置ID */
private Long configId;
/** 所属用户ID */
private Long userId;
/** 邮件 IMAP UID */
private Long uid;
/** 邮件 Message-ID */
private String messageId;
/** 邮件主题 */
private String subject;
/** 发件人地址 */
private String fromAddress;
/** 邮件接收时间 */
private LocalDateTime receivedAt;
/** 附件在邮件中的序号(从0开始) */
private Integer attachmentIndex;
/** 附件文件名 */
private String attachmentName;
/** 附件本地存储相对路径 */
private String attachmentPath;
/** 命中的发件人规则ID */
private Long senderRuleId;
/** 命中的账单类型,未命中时为空 */
private String billType;
/** 处理状态:PULLED-待解析,CONFIRMED-已导入,IGNORED-已忽略 */
private String status;
/** 解析失败原因 */
private String error;
}
@@ -0,0 +1,61 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 邮箱账单发件人规则实体类,对应数据库表 t_email_bill_sender_rule。
*
* <p>按邮件发件人匹配账单类型与默认归类:发件人匹配串命中后,即确定该附件所属的
* 账单类型、压缩包解压密码以及默认分类与标签。</p>
*
* @date 2026-09-19 16:40:24
*/
@Data
@TableName("t_email_bill_sender_rule")
public class EmailBillSenderRule {
/** 规则ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
/** 规则名称 */
private String name;
/** 是否启用:true-启用,false-停用 */
private Boolean enabled;
/** 优先级,数值越小越优先 */
private Integer sortOrder;
/** 发件人匹配串列表(JSON数组),包含匹配且忽略大小写 */
private String senderPatterns;
/** 命中的账单类型:ALIPAY/WECHAT/MEITUAN/CMB/BOCOM */
private String billType;
/** 该来源账单压缩包解压密码,可为空 */
private String archivePassword;
/** 默认支出分类ID,可为空 */
private Long expenseCategoryId;
/** 默认收入分类ID,可为空 */
private Long incomeCategoryId;
/** 默认标签ID列表(JSON数组) */
private String tagIds;
}
@@ -20,6 +20,9 @@ public class HttpLog {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 用户ID,未登录或认证失败时为空 */
private Long userId;
@@ -43,7 +46,4 @@ public class HttpLog {
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -20,6 +20,9 @@ public class LoginLog {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 用户ID,登录失败或用户不存在时为空 */
private Long userId;
@@ -37,7 +40,4 @@ public class LoginLog {
/** User-Agent */
private String userAgent;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -20,6 +20,12 @@ public class NotificationChannel {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
@@ -34,10 +40,4 @@ public class NotificationChannel {
/** 渠道配置(JSON),如 webhook 地址、SMTP 账号等 */
private String config;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -20,6 +20,12 @@ public class NotificationConfig {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
@@ -40,10 +46,4 @@ public class NotificationConfig {
/** 最近一次发送时间,用于避免同一周期重复提醒 */
private LocalDateTime lastSentAt;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -20,6 +20,9 @@ public class NotificationLog {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 所属用户ID */
private Long userId;
@@ -34,7 +37,4 @@ public class NotificationLog {
/** 结果描述或失败原因 */
private String message;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -23,6 +23,12 @@ public class Rule {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
@@ -49,10 +55,4 @@ public class Rule {
/** 命中的目标分类ID,null 表示不指定分类 */
private Long categoryId;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -20,12 +20,12 @@ public class RuleTag {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 规则ID */
private Long ruleId;
/** 标签ID */
private Long tagId;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -20,6 +20,9 @@ public class Tag {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 所属用户ID */
private Long userId;
@@ -31,7 +34,4 @@ public class Tag {
/** 排序序号 */
private Integer sortOrder;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -19,6 +19,12 @@ public class Transaction {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
@@ -51,10 +57,4 @@ public class Transaction {
/** 账单导入的原始完整数据(结构化JSON),手动记录为空 */
private String rawData;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -20,12 +20,12 @@ public class TransactionTag {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 交易记录ID */
private Long transactionId;
/** 标签ID */
private Long tagId;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -18,6 +18,12 @@ public class User {
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 用户名 */
private String username;
@@ -26,10 +32,4 @@ public class User {
/** 昵称 */
private String nickname;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
package cn.rosercode.finance.enums;
/**
* 邮箱账单消息处理状态枚举。
*
* <p>拉取到的账单附件默认处于 {@link #PULLED}(待解析),用户手动解析并导入后置为
* {@link #CONFIRMED},用户主动忽略后置为 {@link #IGNORED}。</p>
*
* @date 2026-09-19 16:40:18
*/
public enum EmailBillStatus {
/** 已拉取,待用户手动解析 */
PULLED("待解析"),
/** 已确认导入 */
CONFIRMED("已导入"),
/** 已忽略 */
IGNORED("已忽略");
/** 状态的中文名称 */
private final String label;
EmailBillStatus(String label) {
this.label = label;
}
/**
* 获取状态的中文名称
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.EmailBillConfig;
/**
* 邮箱账单配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供邮箱配置表的基础CRUD操作。
*
* @date 2026-09-19 16:40:29
*/
@Mapper
public interface EmailBillConfigMapper extends BaseMapper<EmailBillConfig> {
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.EmailBillMessage;
/**
* 邮箱账单消息Mapper接口,继承MyBatis-Plus的BaseMapper,提供账单消息表的基础CRUD操作。
*
* @date 2026-09-19 16:40:35
*/
@Mapper
public interface EmailBillMessageMapper extends BaseMapper<EmailBillMessage> {
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.EmailBillSenderRule;
/**
* 邮箱账单发件人规则Mapper接口,继承MyBatis-Plus的BaseMapper,提供发件人规则表的基础CRUD操作。
*
* @date 2026-09-19 16:40:32
*/
@Mapper
public interface EmailBillSenderRuleMapper extends BaseMapper<EmailBillSenderRule> {
}
@@ -0,0 +1,76 @@
package cn.rosercode.finance.scheduler;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.email.EmailBillMonitorManager;
import cn.rosercode.finance.email.EmailBillPuller;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
/**
* 邮箱账单调度器。
*
* <p>定期同步监听线程(保证启用配置都有常驻监听),并对所有启用配置执行兜底增量检查,
* 防止 IDLE 漏事件导致账单邮件未被拉取。</p>
*
* @date 2026-09-19 16:45:16
*/
@Component
@Slf4j
public class EmailBillScheduler {
/** 兜底检查间隔(毫秒) */
private static final long FALLBACK_INTERVAL_MS = 300000L;
private final EmailBillMonitorManager monitorManager;
private final EmailBillPuller puller;
private final EmailBillConfigMapper configMapper;
public EmailBillScheduler(EmailBillMonitorManager monitorManager,
EmailBillPuller puller,
EmailBillConfigMapper configMapper) {
this.monitorManager = monitorManager;
this.puller = puller;
this.configMapper = configMapper;
}
/**
* 定期同步监听线程,补启动数据库就绪后新增或遗漏的监听。
*/
@Scheduled(fixedDelay = 60000L, initialDelay = 30000L)
public void syncMonitors() {
try {
monitorManager.sync();
} catch (Exception e) {
log.debug("同步邮箱账单监听失败:{}", e.getMessage());
}
}
/**
* 定时兜底增量检查:逐个启用配置拉取一次,失败不影响其他配置。
*/
@Scheduled(fixedDelay = FALLBACK_INTERVAL_MS, initialDelay = 90000L)
public void fallbackCheck() {
List<EmailBillConfig> configs;
try {
configs = configMapper.selectList(new LambdaQueryWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getEnabled, true)
.orderByAsc(EmailBillConfig::getId));
} catch (Exception e) {
log.debug("跳过邮箱账单兜底检查,读取配置失败:{}", e.getMessage());
return;
}
for (EmailBillConfig config : configs) {
try {
puller.pull(config);
} catch (Exception e) {
log.warn("邮箱账单兜底检查失败: configId={}, error={}", config.getId(), e.getMessage());
}
}
}
}
@@ -0,0 +1,147 @@
package cn.rosercode.finance.service;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.email.EmailBillPuller;
import cn.rosercode.finance.entity.EmailBillConfig;
/**
* 邮箱账单立即检查的实时进度推送服务。
*
* <p>基于 SSE 将「立即检查」的执行进度实时推送给前端:先发送 {@code meta} 事件,
* 随后逐行推送 {@code line} 事件,正常结束发送 {@code done}(含新增待处理条数),
* 异常时发送 {@code failed}(含失败原因)。同一配置同时只允许一个检查任务。</p>
*
* @date 2026-09-19 17:21:46
*/
@Slf4j
@Service
public class EmailBillCheckStreamService {
private final EmailBillPuller puller;
private final EmailBillConfigService configService;
/** 执行检查任务的守护线程池 */
private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
Thread thread = new Thread(runnable, "email-bill-check-stream");
thread.setDaemon(true);
return thread;
});
/** 正在检查的配置ID集合 */
private final Map<Long, Boolean> running = new ConcurrentHashMap<>();
public EmailBillCheckStreamService(EmailBillPuller puller, EmailBillConfigService configService) {
this.puller = puller;
this.configService = configService;
}
/**
* 建立立即检查的实时进度流。
*
* @param userId 当前用户ID
* @param configId 邮箱配置ID
* @return SSE 发射器
*/
public SseEmitter stream(Long userId, Long configId) {
EmailBillConfig config = configService.getRawConfig(userId, configId);
if (config == null) {
throw new BusinessException("邮箱配置不存在");
}
SseEmitter emitter = new SseEmitter(0L);
if (running.putIfAbsent(configId, Boolean.TRUE) != null) {
executor.submit(() -> {
trySend(emitter, "line", "已有检查正在进行中,请稍候");
complete(emitter);
});
return emitter;
}
executor.submit(() -> runCheck(emitter, config));
return emitter;
}
/**
* 应用关闭时停止线程池。
*/
@PreDestroy
public void shutdown() {
executor.shutdownNow();
}
/**
* 执行检查并把进度推送到发射器。
*
* @param emitter SSE 发射器
* @param config 邮箱配置
*/
private void runCheck(SseEmitter emitter, EmailBillConfig config) {
try {
trySend(emitter, "meta", "{\"configId\":" + config.getId()
+ ",\"name\":\"" + escape(config.getName()) + "\""
+ ",\"host\":\"" + escape(config.getHost()) + "\"}");
int count = puller.pull(config, line -> trySend(emitter, "line", line));
trySend(emitter, "done", "{\"count\":" + count + "}");
} catch (RuntimeException e) {
log.warn("邮箱账单立即检查失败: configId={}, error={}", config.getId(), e.getMessage());
trySend(emitter, "failed", "{\"message\":\"" + escape(e.getMessage()) + "\"}");
} finally {
running.remove(config.getId());
complete(emitter);
}
}
/**
* 推送一个 SSE 事件,客户端已断开时静默失败。
*
* @param emitter SSE 发射器
* @param event 事件名
* @param data 事件数据
*/
private void trySend(SseEmitter emitter, String event, String data) {
try {
emitter.send(SseEmitter.event().name(event).data(data));
} catch (IOException | IllegalStateException e) {
// 连接已断开或发射器已结束,忽略
}
}
/**
* 结束 SSE 连接,忽略已完成或已断开的异常。
*
* @param emitter SSE 发射器
*/
private void complete(SseEmitter emitter) {
try {
emitter.complete();
} catch (Exception ignored) {
// 连接可能已断开
}
}
/**
* 转义 JSON 字符串中的特殊字符。
*
* @param value 原始字符串
* @return 转义后的字符串
*/
private String escape(String value) {
if (value == null) {
return "";
}
return value.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", " ")
.replace("\r", " ");
}
}
@@ -0,0 +1,82 @@
package cn.rosercode.finance.service;
import java.util.List;
import cn.rosercode.finance.dto.EmailBillConfigRequest;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.vo.EmailBillConfigVO;
/**
* 邮箱账单配置服务,负责 IMAP 邮箱配置的增删改查、启停与连接测试。
*
* @date 2026-09-19 16:41:17
*/
public interface EmailBillConfigService {
/**
* 查询当前用户的全部邮箱配置。
*
* @param userId 当前用户ID
* @return 邮箱配置视图对象列表
*/
List<EmailBillConfigVO> list(Long userId);
/**
* 新增邮箱配置。
*
* @param userId 当前用户ID
* @param request 配置请求
* @return 新配置ID
*/
Long create(Long userId, EmailBillConfigRequest request);
/**
* 更新邮箱配置,授权码留空表示不修改。
*
* @param userId 当前用户ID
* @param id 配置ID
* @param request 配置请求
*/
void update(Long userId, Long id, EmailBillConfigRequest request);
/**
* 删除邮箱配置,同时清理其拉取到的消息记录。
*
* @param userId 当前用户ID
* @param id 配置ID
*/
void delete(Long userId, Long id);
/**
* 切换邮箱配置的启用状态。
*
* @param userId 当前用户ID
* @param id 配置ID
* @param enabled 是否启用
*/
void toggleEnabled(Long userId, Long id, Boolean enabled);
/**
* 测试邮箱配置的 IMAP 连接是否可用,并回写连接状态。
*
* @param userId 当前用户ID
* @param id 配置ID
*/
void testConnection(Long userId, Long id);
/**
* 获取归属当前用户的邮箱配置实体。
*
* @param userId 当前用户ID
* @param id 配置ID
* @return 邮箱配置实体
*/
EmailBillConfig getRawConfig(Long userId, Long id);
/**
* 查询所有已启用的邮箱配置,供后台监听线程加载。
*
* @return 已启用的邮箱配置列表
*/
List<EmailBillConfig> listEnabledConfigs();
}
@@ -0,0 +1,64 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.BillImportRequest;
import cn.rosercode.finance.dto.EmailBillParseRequest;
import cn.rosercode.finance.vo.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.EmailBillMessageVO;
/**
* 邮箱账单消息服务,负责待处理列表与「解析、选择、入库」三步流程。
*
* @date 2026-09-19 16:48:42
*/
public interface EmailBillMessageService {
/**
* 分页查询拉取到的账单消息。
*
* @param userId 当前用户ID
* @param status 处理状态筛选,可为空
* @param configId 邮箱配置ID筛选,可为空
* @param page 页码,从 1 开始
* @param size 每页大小
* @return 分页结果
*/
PageResult<EmailBillMessageVO> page(Long userId, String status, Long configId, int page, int size);
/**
* 解析指定消息的本地附件,返回预览行(不落库)。
*
* @param userId 当前用户ID
* @param messageId 消息ID
* @param request 解析请求,可覆盖账单类型与解压密码
* @return 解析预览结果
*/
BillParseResultVO parse(Long userId, Long messageId, EmailBillParseRequest request);
/**
* 导入用户在预览中勾选的账单行,成功后标记消息为已导入。
*
* @param userId 当前用户ID
* @param messageId 消息ID
* @param request 导入请求
* @return 导入结果统计
*/
BillImportResultVO importBill(Long userId, Long messageId, BillImportRequest request);
/**
* 忽略指定消息。
*
* @param userId 当前用户ID
* @param messageId 消息ID
*/
void ignore(Long userId, Long messageId);
/**
* 删除指定消息及其本地附件。
*
* @param userId 当前用户ID
* @param messageId 消息ID
*/
void delete(Long userId, Long messageId);
}
@@ -0,0 +1,66 @@
package cn.rosercode.finance.service;
import java.util.List;
import cn.rosercode.finance.dto.EmailBillSenderRuleRequest;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.vo.EmailBillSenderRuleVO;
/**
* 邮箱账单发件人规则服务,负责发件人识别规则的增删改查与启用停用。
*
* @date 2026-09-19 16:40:50
*/
public interface EmailBillSenderRuleService {
/**
* 查询当前用户的全部发件人规则。
*
* @param userId 当前用户ID
* @return 规则视图对象列表,按优先级升序
*/
List<EmailBillSenderRuleVO> list(Long userId);
/**
* 新增发件人规则。
*
* @param userId 当前用户ID
* @param request 规则请求
* @return 新规则ID
*/
Long create(Long userId, EmailBillSenderRuleRequest request);
/**
* 更新发件人规则。
*
* @param userId 当前用户ID
* @param id 规则ID
* @param request 规则请求
*/
void update(Long userId, Long id, EmailBillSenderRuleRequest request);
/**
* 删除发件人规则。
*
* @param userId 当前用户ID
* @param id 规则ID
*/
void delete(Long userId, Long id);
/**
* 切换发件人规则的启用状态。
*
* @param userId 当前用户ID
* @param id 规则ID
* @param enabled 是否启用
*/
void toggleEnabled(Long userId, Long id, Boolean enabled);
/**
* 查询当前用户所有已启用的发件人规则,供邮件拉取时匹配。
*
* @param userId 当前用户ID
* @return 已启用规则列表,按优先级升序
*/
List<EmailBillSenderRule> listEnabledRules(Long userId);
}
@@ -2,17 +2,21 @@ package cn.rosercode.finance.service.impl;
import java.util.List;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
import cn.rosercode.finance.mapper.TagMapper;
/**
* 默认数据初始化器。
*
* <p>为新注册用户创建一套默认分类与标签。相比原先需要手动执行的 SQL 种子脚本,
* <p>为新注册用户创建一套默认分类、标签与邮箱账单发件人规则。相比原先需要手动执行的 SQL 种子脚本,
* 使用 Java 代码插入可同时兼容 MySQL 与 SQLite 两种数据库。</p>
*
* @date 2026-09-18 11:02:15
@@ -59,16 +63,36 @@ public class DefaultDataInitializer {
new TagSeed("宠物", "#a16207", 120),
new TagSeed("电影", "#14b8a6", 130));
/** 默认邮箱账单发件人规则(名称、发件人匹配串、账单类型、排序) */
private static final List<SenderRuleSeed> DEFAULT_SENDER_RULES = List.of(
new SenderRuleSeed("支付宝账单",
List.of("service@mail.alipay.com", "@alipay.com"), "ALIPAY", 10),
new SenderRuleSeed("微信支付账单",
List.of("@wechatpay.com", "@tenpay.com"), "WECHAT", 20),
new SenderRuleSeed("美团账单",
List.of("it_paybill@meituan.com", "@meituan.com"), "MEITUAN", 30),
new SenderRuleSeed("招商银行账单",
List.of("95555@message.cmbchina.com", "@message.cmbchina.com"), "CMB", 40),
new SenderRuleSeed("交通银行账单",
List.of("@bankcomm.com"), "BOCOM", 50));
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final EmailBillSenderRuleMapper emailBillSenderRuleMapper;
private final ObjectMapper objectMapper;
public DefaultDataInitializer(CategoryMapper categoryMapper, TagMapper tagMapper) {
public DefaultDataInitializer(CategoryMapper categoryMapper,
TagMapper tagMapper,
EmailBillSenderRuleMapper emailBillSenderRuleMapper,
ObjectMapper objectMapper) {
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.emailBillSenderRuleMapper = emailBillSenderRuleMapper;
this.objectMapper = objectMapper;
}
/**
* 为指定用户初始化默认分类与标签。
* 为指定用户初始化默认分类、标签与发件人规则。
*
* @param userId 用户ID
*/
@@ -87,6 +111,16 @@ public class DefaultDataInitializer {
tag.setSortOrder(seed.sortOrder());
tagMapper.insert(tag);
}
for (SenderRuleSeed seed : DEFAULT_SENDER_RULES) {
EmailBillSenderRule rule = new EmailBillSenderRule();
rule.setUserId(userId);
rule.setName(seed.name());
rule.setEnabled(true);
rule.setSortOrder(seed.sortOrder());
rule.setSenderPatterns(writePatterns(seed.patterns()));
rule.setBillType(seed.billType());
emailBillSenderRuleMapper.insert(rule);
}
}
/**
@@ -106,6 +140,20 @@ public class DefaultDataInitializer {
categoryMapper.insert(category);
}
/**
* 将发件人匹配串序列化为 JSON 数组。
*
* @param patterns 匹配串列表
* @return JSON 字符串
*/
private String writePatterns(List<String> patterns) {
try {
return objectMapper.writeValueAsString(patterns);
} catch (JsonProcessingException e) {
throw new IllegalStateException("序列化默认发件人规则失败:" + e.getMessage(), e);
}
}
/**
* 分类种子数据。
*
@@ -125,4 +173,15 @@ public class DefaultDataInitializer {
*/
private record TagSeed(String name, String color, int sortOrder) {
}
/**
* 发件人规则种子数据。
*
* @param name 规则名称
* @param patterns 发件人匹配串列表
* @param billType 账单类型
* @param sortOrder 排序序号
*/
private record SenderRuleSeed(String name, List<String> patterns, String billType, int sortOrder) {
}
}
@@ -0,0 +1,416 @@
package cn.rosercode.finance.service.impl;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.mail.Folder;
import jakarta.mail.MessagingException;
import jakarta.mail.Store;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.EmailBillConfigRequest;
import cn.rosercode.finance.email.EmailBillMonitorManager;
import cn.rosercode.finance.email.EmailImapConnector;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillConfig;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillConfigMapper;
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.EmailBillConfigService;
import cn.rosercode.finance.vo.EmailBillConfigVO;
/**
* 邮箱账单配置服务实现类,处理 IMAP 邮箱配置的增删改查、启停与连接测试。
*
* <p>授权码明文持久化但不在视图对象中返回;测试连接成功或失败都会回写连接状态,
* 便于前端直接展示邮箱的可用性。</p>
*
* @date 2026-09-19 16:41:23
*/
@Service
@Slf4j
public class EmailBillConfigServiceImpl implements EmailBillConfigService {
/** 默认 IMAP 端口 */
private static final int DEFAULT_PORT = 993;
/** 默认监听文件夹 */
private static final String DEFAULT_FOLDER = "INBOX";
/** 失败原因最大长度 */
private static final int ERROR_LIMIT = 500;
/** 拉取范围:仅新邮件 */
private static final String SCOPE_INCREMENT = "INCREMENT";
/** 拉取范围:最近 N 天 */
private static final String SCOPE_DAYS = "DAYS";
/** 拉取范围:最近 N 封 */
private static final String SCOPE_COUNT = "COUNT";
private final EmailBillConfigMapper configMapper;
private final EmailBillMessageMapper messageMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final ObjectMapper objectMapper;
private final VoConverter voConverter;
private final EmailImapConnector imapConnector;
private final EmailBillMonitorManager monitorManager;
public EmailBillConfigServiceImpl(EmailBillConfigMapper configMapper,
EmailBillMessageMapper messageMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
ObjectMapper objectMapper,
VoConverter voConverter,
EmailImapConnector imapConnector,
EmailBillMonitorManager monitorManager) {
this.configMapper = configMapper;
this.messageMapper = messageMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.objectMapper = objectMapper;
this.voConverter = voConverter;
this.imapConnector = imapConnector;
this.monitorManager = monitorManager;
}
/**
* {@inheritDoc}
*/
@Override
public List<EmailBillConfigVO> list(Long userId) {
List<EmailBillConfig> configs = configMapper.selectList(new LambdaQueryWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getUserId, userId)
.orderByAsc(EmailBillConfig::getId));
List<EmailBillConfigVO> result = new ArrayList<>();
for (EmailBillConfig config : configs) {
result.add(toVO(config));
}
return result;
}
/**
* {@inheritDoc}
*
* 新增时授权码必填。
*/
@Override
@Transactional
public Long create(Long userId, EmailBillConfigRequest request) {
if (!StringUtils.hasText(request.getPassword())) {
throw new BusinessException("请填写邮箱授权码");
}
validateCategories(userId, request);
EmailBillConfig config = new EmailBillConfig();
config.setUserId(userId);
applyRequest(config, request);
config.setStatus("DISCONNECTED");
configMapper.insert(config);
monitorManager.refresh(config.getId());
log.info("新增邮箱账单配置: userId={}, id={}, host={}", userId, config.getId(), config.getHost());
return config.getId();
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void update(Long userId, Long id, EmailBillConfigRequest request) {
EmailBillConfig config = getRawConfig(userId, id);
validateCategories(userId, request);
applyRequest(config, request);
configMapper.updateById(config);
monitorManager.refresh(id);
log.info("更新邮箱账单配置: userId={}, id={}, host={}", userId, id, config.getHost());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
getRawConfig(userId, id);
messageMapper.delete(new LambdaQueryWrapper<EmailBillMessage>()
.eq(EmailBillMessage::getConfigId, id));
configMapper.deleteById(id);
monitorManager.refresh(id);
log.info("删除邮箱账单配置: userId={}, id={}", userId, id);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void toggleEnabled(Long userId, Long id, Boolean enabled) {
EmailBillConfig config = getRawConfig(userId, id);
config.setEnabled(enabled == null ? Boolean.TRUE : enabled);
configMapper.updateById(config);
monitorManager.refresh(id);
log.info("切换邮箱账单配置状态: userId={}, id={}, enabled={}", userId, id, config.getEnabled());
}
/**
* {@inheritDoc}
*/
@Override
public void testConnection(Long userId, Long id) {
EmailBillConfig config = getRawConfig(userId, id);
Store store = null;
Folder folder = null;
try {
store = imapConnector.connect(config);
folder = store.getFolder(config.getFolder());
folder.open(Folder.READ_ONLY);
config.setStatus("CONNECTED");
config.setLastError(null);
config.setLastConnectedAt(LocalDateTime.now());
configMapper.updateById(config);
log.info("邮箱账单配置连接测试成功: userId={}, id={}, host={}", userId, id, config.getHost());
} catch (MessagingException e) {
config.setStatus("ERROR");
config.setLastError(truncate(e.getMessage()));
configMapper.updateById(config);
log.warn("邮箱账单配置连接测试失败: userId={}, id={}, error={}", userId, id, e.getMessage());
throw new BusinessException("连接失败:" + e.getMessage());
} finally {
closeQuietly(folder);
closeQuietly(store);
}
}
/**
* {@inheritDoc}
*/
@Override
public EmailBillConfig getRawConfig(Long userId, Long id) {
EmailBillConfig config = configMapper.selectById(id);
if (config == null || !config.getUserId().equals(userId)) {
throw new BusinessException("邮箱配置不存在");
}
return config;
}
/**
* {@inheritDoc}
*/
@Override
public List<EmailBillConfig> listEnabledConfigs() {
return configMapper.selectList(new LambdaQueryWrapper<EmailBillConfig>()
.eq(EmailBillConfig::getEnabled, true)
.orderByAsc(EmailBillConfig::getId));
}
/**
* 将请求字段写入配置实体,统一处理默认值;授权码留空时保留原值。
*
* @param config 目标配置实体
* @param request 配置请求参数
*/
private void applyRequest(EmailBillConfig config, EmailBillConfigRequest request) {
config.setName(request.getName().trim());
config.setHost(request.getHost().trim());
config.setPort(request.getPort() != null ? request.getPort() : DEFAULT_PORT);
config.setUseSsl(request.getSsl() == null || request.getSsl());
config.setUsername(request.getUsername().trim());
// 授权码留空表示不修改
if (StringUtils.hasText(request.getPassword())) {
config.setPassword(request.getPassword().trim());
}
config.setFolder(StringUtils.hasText(request.getFolder()) ? request.getFolder().trim() : DEFAULT_FOLDER);
config.setEnabled(request.getEnabled() != null && request.getEnabled());
config.setExpenseCategoryId(request.getExpenseCategoryId());
config.setIncomeCategoryId(request.getIncomeCategoryId());
List<Long> tagIds = validTagIds(request.getTagIds());
config.setTagIds(tagIds.isEmpty() ? null : writeJson(tagIds));
config.setFetchScope(resolveFetchScope(request.getFetchScope()));
config.setFetchLimit(resolveFetchLimit(config.getFetchScope(), request.getFetchLimit()));
}
/**
* 规范化拉取范围,非法值回退为增量模式。
*
* @param scope 原始范围
* @return INCREMENT / DAYS / COUNT
*/
private String resolveFetchScope(String scope) {
if (!StringUtils.hasText(scope)) {
return SCOPE_INCREMENT;
}
String upper = scope.trim().toUpperCase();
if (SCOPE_DAYS.equals(upper) || SCOPE_COUNT.equals(upper)) {
return upper;
}
return SCOPE_INCREMENT;
}
/**
* 规范化拉取范围数量,按范围类型回退默认值。
*
* @param scope 拉取范围
* @param limit 原始数量
* @return 规范化后的数量,增量模式返回 null
*/
private Integer resolveFetchLimit(String scope, Integer limit) {
if (SCOPE_DAYS.equals(scope)) {
return limit != null && limit > 0 ? limit : 1;
}
if (SCOPE_COUNT.equals(scope)) {
return limit != null && limit > 0 ? limit : 20;
}
return null;
}
/**
* 校验默认分类的归属与类型一致性。
*
* @param userId 当前用户ID
* @param request 配置请求参数
*/
private void validateCategories(Long userId, EmailBillConfigRequest request) {
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
}
/**
* 校验分类是否存在、归属当前用户且类型匹配。
*
* @param userId 当前用户ID
* @param categoryId 分类ID,可为空
* @param type 期望的分类类型
*/
private void validateCategory(Long userId, Long categoryId, String type) {
if (categoryId == null) {
return;
}
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("默认分类与收支类型不匹配");
}
}
/**
* 过滤出属于当前用户的有效标签ID。
*
* @param tagIds 待校验的标签ID列表
* @return 有效的标签ID列表
*/
private List<Long> validTagIds(List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return List.of();
}
return tagMapper.selectBatchIds(tagIds).stream()
.map(Tag::getId)
.toList();
}
/**
* 将配置实体转为视图对象,还原默认标签列表。
*
* @param config 配置实体
* @return 配置视图对象
*/
private EmailBillConfigVO toVO(EmailBillConfig config) {
EmailBillConfigVO vo = voConverter.toEmailBillConfigVO(config);
vo.setTagIds(parseLongList(config.getTagIds()));
return vo;
}
/**
* 序列化对象为 JSON 字符串。
*
* @param value 待序列化对象
* @return JSON 字符串
*/
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new BusinessException("序列化邮箱配置失败:" + e.getMessage());
}
}
/**
* 解析 JSON 数字数组。
*
* @param json JSON 字符串
* @return 数字列表,解析失败或为空时返回空列表
*/
private List<Long> parseLongList(String json) {
if (!StringUtils.hasText(json)) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<Long>>() {
});
} catch (JsonProcessingException e) {
log.warn("解析默认标签列表失败:{}", e.getMessage());
return List.of();
}
}
/**
* 静默关闭邮件文件夹。
*
* @param folder 文件夹,可为 null
*/
private void closeQuietly(Folder folder) {
if (folder == null || !folder.isOpen()) {
return;
}
try {
folder.close(false);
} catch (MessagingException ignored) {
// 关闭失败不影响测试结果
}
}
/**
* 静默关闭邮件连接。
*
* @param store 连接,可为 null
*/
private void closeQuietly(Store store) {
if (store == null) {
return;
}
try {
store.close();
} catch (MessagingException ignored) {
// 关闭失败不影响测试结果
}
}
/**
* 截断过长的失败原因。
*
* @param message 原文
* @return 截断后的文本
*/
private String truncate(String message) {
if (message == null) {
return null;
}
return message.length() <= ERROR_LIMIT ? message : message.substring(0, ERROR_LIMIT);
}
}
@@ -0,0 +1,243 @@
package cn.rosercode.finance.service.impl;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.BillImportRequest;
import cn.rosercode.finance.dto.EmailBillParseRequest;
import cn.rosercode.finance.email.EmailBillStorage;
import cn.rosercode.finance.entity.EmailBillMessage;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.enums.EmailBillStatus;
import cn.rosercode.finance.mapper.EmailBillMessageMapper;
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
import cn.rosercode.finance.service.BillImportService;
import cn.rosercode.finance.service.EmailBillMessageService;
import cn.rosercode.finance.vo.BillImportResultVO;
import cn.rosercode.finance.vo.BillParseResultVO;
import cn.rosercode.finance.vo.EmailBillMessageVO;
/**
* 邮箱账单消息服务实现类,实现待处理列表与「解析、选择、入库」三步流程。
*
* <p>解析阶段读取本地附件并复用账单导入服务,返回预览行但不落库;
* 导入阶段复用账单导入服务写入交易记录,成功后标记消息为已导入。</p>
*
* @date 2026-09-19 16:48:46
*/
@Service
@Slf4j
public class EmailBillMessageServiceImpl implements EmailBillMessageService {
/** 单页最大条数 */
private static final int MAX_PAGE_SIZE = 100;
/** 失败原因最大长度 */
private static final int ERROR_LIMIT = 500;
private final EmailBillMessageMapper messageMapper;
private final EmailBillSenderRuleMapper senderRuleMapper;
private final EmailBillStorage storage;
private final BillImportService billImportService;
private final VoConverter voConverter;
public EmailBillMessageServiceImpl(EmailBillMessageMapper messageMapper,
EmailBillSenderRuleMapper senderRuleMapper,
EmailBillStorage storage,
BillImportService billImportService,
VoConverter voConverter) {
this.messageMapper = messageMapper;
this.senderRuleMapper = senderRuleMapper;
this.storage = storage;
this.billImportService = billImportService;
this.voConverter = voConverter;
}
/**
* {@inheritDoc}
*/
@Override
public PageResult<EmailBillMessageVO> page(Long userId, String status, Long configId, int page, int size) {
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
LambdaQueryWrapper<EmailBillMessage> wrapper = new LambdaQueryWrapper<EmailBillMessage>()
.eq(EmailBillMessage::getUserId, userId)
.eq(StringUtils.hasText(status), EmailBillMessage::getStatus, status)
.eq(configId != null, EmailBillMessage::getConfigId, configId);
Long total = messageMapper.selectCount(wrapper);
long offset = (long) (safePage - 1) * safeSize;
List<EmailBillMessage> messages = messageMapper.selectList(wrapper
.orderByDesc(EmailBillMessage::getId)
.last("LIMIT " + safeSize + " OFFSET " + offset));
List<EmailBillMessageVO> records = new ArrayList<>();
for (EmailBillMessage message : messages) {
records.add(voConverter.toEmailBillMessageVO(message));
}
return new PageResult<>(records, total == null ? 0L : total, safePage, safeSize);
}
/**
* {@inheritDoc}
*
* 账单类型与解压密码默认取发件人规则,请求中提供时覆盖;解析失败会记录失败原因。
*/
@Override
public BillParseResultVO parse(Long userId, Long messageId, EmailBillParseRequest request) {
EmailBillMessage message = requireMessage(userId, messageId);
BillType billType = resolveBillType(message, request);
String password = resolvePassword(message, request);
byte[] content;
try {
content = storage.read(message.getAttachmentPath());
} catch (RuntimeException e) {
updateError(message, "读取附件失败:" + e.getMessage());
throw e;
}
try {
BillParseResultVO result = billImportService.parse(userId, billType, content, password);
updateError(message, null);
return result;
} catch (IOException e) {
updateError(message, "解析失败:" + e.getMessage());
throw new BusinessException("解析失败:" + e.getMessage());
} catch (RuntimeException e) {
updateError(message, e.getMessage());
throw e;
}
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public BillImportResultVO importBill(Long userId, Long messageId, BillImportRequest request) {
EmailBillMessage message = requireMessage(userId, messageId);
BillImportResultVO result = billImportService.confirm(userId, request);
message.setStatus(EmailBillStatus.CONFIRMED.name());
message.setError(null);
messageMapper.updateById(message);
log.info("邮箱账单导入完成: userId={}, messageId={}, 成功={}", userId, messageId, result.getImported());
return result;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void ignore(Long userId, Long messageId) {
EmailBillMessage message = requireMessage(userId, messageId);
message.setStatus(EmailBillStatus.IGNORED.name());
messageMapper.updateById(message);
log.info("忽略邮箱账单消息: userId={}, messageId={}", userId, messageId);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void delete(Long userId, Long messageId) {
EmailBillMessage message = requireMessage(userId, messageId);
storage.delete(message.getAttachmentPath());
messageMapper.deleteById(messageId);
log.info("删除邮箱账单消息: userId={}, messageId={}", userId, messageId);
}
/**
* 获取归属当前用户的消息实体。
*
* @param userId 当前用户ID
* @param messageId 消息ID
* @return 消息实体
*/
private EmailBillMessage requireMessage(Long userId, Long messageId) {
EmailBillMessage message = messageMapper.selectById(messageId);
if (message == null || !message.getUserId().equals(userId)) {
throw new BusinessException("账单消息不存在");
}
return message;
}
/**
* 解析账单类型:优先请求指定,其次发件人规则识别结果。
*
* @param message 消息实体
* @param request 解析请求
* @return 账单类型
*/
private BillType resolveBillType(EmailBillMessage message, EmailBillParseRequest request) {
String value = request != null && StringUtils.hasText(request.getBillType())
? request.getBillType() : message.getBillType();
if (!StringUtils.hasText(value)) {
throw new BusinessException("请选择账单类型");
}
try {
return BillType.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的账单类型:" + value);
}
}
/**
* 解析解压密码:优先请求指定,其次发件人规则配置。
*
* @param message 消息实体
* @param request 解析请求
* @return 解压密码,均为空时返回 null
*/
private String resolvePassword(EmailBillMessage message, EmailBillParseRequest request) {
if (request != null && StringUtils.hasText(request.getPassword())) {
return request.getPassword().trim();
}
if (message.getSenderRuleId() != null) {
EmailBillSenderRule rule = senderRuleMapper.selectById(message.getSenderRuleId());
if (rule != null && StringUtils.hasText(rule.getArchivePassword())) {
return rule.getArchivePassword();
}
}
return null;
}
/**
* 更新消息的解析失败原因。
*
* @param message 消息实体
* @param error 失败原因,null 表示清空
*/
private void updateError(EmailBillMessage message, String error) {
messageMapper.update(null, new LambdaUpdateWrapper<EmailBillMessage>()
.eq(EmailBillMessage::getId, message.getId())
.set(EmailBillMessage::getError, truncate(error)));
}
/**
* 截断过长的失败原因。
*
* @param error 原文
* @return 截断后的文本
*/
private String truncate(String error) {
if (error == null) {
return null;
}
return error.length() <= ERROR_LIMIT ? error : error.substring(0, ERROR_LIMIT);
}
}
@@ -0,0 +1,307 @@
package cn.rosercode.finance.service.impl;
import java.util.ArrayList;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.EmailBillSenderRuleRequest;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.EmailBillSenderRule;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.enums.BillType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.EmailBillSenderRuleMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.service.EmailBillSenderRuleService;
import cn.rosercode.finance.vo.EmailBillSenderRuleVO;
/**
* 邮箱账单发件人规则服务实现类,处理发件人识别规则的增删改查与匹配数据准备。
*
* <p>发件人匹配串与默认标签以 JSON 数组持久化,视图对象中还原为列表;
* 匹配本身在邮件拉取时由 {@code EmailBillProcessor} 按优先级完成。</p>
*
* @date 2026-09-19 16:41:07
*/
@Service
@Slf4j
public class EmailBillSenderRuleServiceImpl implements EmailBillSenderRuleService {
private final EmailBillSenderRuleMapper senderRuleMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final ObjectMapper objectMapper;
private final VoConverter voConverter;
public EmailBillSenderRuleServiceImpl(EmailBillSenderRuleMapper senderRuleMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
ObjectMapper objectMapper,
VoConverter voConverter) {
this.senderRuleMapper = senderRuleMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.objectMapper = objectMapper;
this.voConverter = voConverter;
}
/**
* {@inheritDoc}
*/
@Override
public List<EmailBillSenderRuleVO> list(Long userId) {
List<EmailBillSenderRule> rules = senderRuleMapper.selectList(new LambdaQueryWrapper<EmailBillSenderRule>()
.eq(EmailBillSenderRule::getUserId, userId)
.orderByAsc(EmailBillSenderRule::getSortOrder)
.orderByAsc(EmailBillSenderRule::getId));
List<EmailBillSenderRuleVO> result = new ArrayList<>();
for (EmailBillSenderRule rule : rules) {
result.add(toVO(rule));
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public Long create(Long userId, EmailBillSenderRuleRequest request) {
validate(userId, request);
EmailBillSenderRule rule = new EmailBillSenderRule();
rule.setUserId(userId);
applyRequest(rule, request);
senderRuleMapper.insert(rule);
log.info("新增邮箱账单发件人规则: userId={}, id={}, name={}", userId, rule.getId(), rule.getName());
return rule.getId();
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void update(Long userId, Long id, EmailBillSenderRuleRequest request) {
EmailBillSenderRule rule = senderRuleMapper.selectById(id);
if (rule == null || !rule.getUserId().equals(userId)) {
throw new BusinessException("发件人规则不存在");
}
validate(userId, request);
applyRequest(rule, request);
senderRuleMapper.updateById(rule);
log.info("更新邮箱账单发件人规则: userId={}, id={}, name={}", userId, id, rule.getName());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
EmailBillSenderRule rule = senderRuleMapper.selectById(id);
if (rule == null || !rule.getUserId().equals(userId)) {
throw new BusinessException("发件人规则不存在");
}
senderRuleMapper.deleteById(id);
log.info("删除邮箱账单发件人规则: userId={}, id={}", userId, id);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void toggleEnabled(Long userId, Long id, Boolean enabled) {
EmailBillSenderRule rule = senderRuleMapper.selectById(id);
if (rule == null || !rule.getUserId().equals(userId)) {
throw new BusinessException("发件人规则不存在");
}
rule.setEnabled(enabled == null ? Boolean.TRUE : enabled);
senderRuleMapper.updateById(rule);
log.info("切换邮箱账单发件人规则状态: userId={}, id={}, enabled={}", userId, id, rule.getEnabled());
}
/**
* {@inheritDoc}
*/
@Override
public List<EmailBillSenderRule> listEnabledRules(Long userId) {
return senderRuleMapper.selectList(new LambdaQueryWrapper<EmailBillSenderRule>()
.eq(EmailBillSenderRule::getUserId, userId)
.eq(EmailBillSenderRule::getEnabled, true)
.orderByAsc(EmailBillSenderRule::getSortOrder)
.orderByAsc(EmailBillSenderRule::getId));
}
/**
* 将请求字段写入规则实体,统一处理默认值与 JSON 序列化。
*
* @param rule 目标规则实体
* @param request 规则请求参数
*/
private void applyRequest(EmailBillSenderRule rule, EmailBillSenderRuleRequest request) {
rule.setName(request.getName().trim());
rule.setEnabled(request.getEnabled() == null ? Boolean.TRUE : request.getEnabled());
rule.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
rule.setSenderPatterns(writeJson(cleanPatterns(request.getSenderPatterns())));
rule.setBillType(request.getBillType().trim().toUpperCase());
rule.setArchivePassword(StringUtils.hasText(request.getArchivePassword())
? request.getArchivePassword().trim() : null);
rule.setExpenseCategoryId(request.getExpenseCategoryId());
rule.setIncomeCategoryId(request.getIncomeCategoryId());
List<Long> tagIds = validTagIds(request.getTagIds());
rule.setTagIds(tagIds.isEmpty() ? null : writeJson(tagIds));
}
/**
* 校验账单类型、发件人匹配串、默认分类与标签的合法性。
*
* @param userId 当前用户ID
* @param request 规则请求参数
*/
private void validate(Long userId, EmailBillSenderRuleRequest request) {
// 账单类型白名单
try {
BillType.valueOf(request.getBillType().trim().toUpperCase());
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的账单类型:" + request.getBillType());
}
// 至少一个非空发件人匹配串
if (cleanPatterns(request.getSenderPatterns()).isEmpty()) {
throw new BusinessException("请至少填写一个发件人匹配串");
}
// 默认分类归属与类型一致性
validateCategory(userId, request.getExpenseCategoryId(), "EXPENSE");
validateCategory(userId, request.getIncomeCategoryId(), "INCOME");
}
/**
* 校验分类是否存在、归属当前用户且类型匹配。
*
* @param userId 当前用户ID
* @param categoryId 分类ID,可为空
* @param type 期望的分类类型
*/
private void validateCategory(Long userId, Long categoryId, String type) {
if (categoryId == null) {
return;
}
Category category = categoryMapper.selectById(categoryId);
if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
if (!type.equals(category.getType())) {
throw new BusinessException("默认分类与收支类型不匹配");
}
}
/**
* 过滤出属于当前用户的有效标签ID。
*
* @param tagIds 待校验的标签ID列表
* @return 有效的标签ID列表
*/
private List<Long> validTagIds(List<Long> tagIds) {
if (tagIds == null || tagIds.isEmpty()) {
return List.of();
}
return tagMapper.selectBatchIds(tagIds).stream()
.map(Tag::getId)
.toList();
}
/**
* 清理发件人匹配串:去空白、去空项、去重。
*
* @param patterns 原始匹配串列表
* @return 清理后的匹配串列表
*/
private List<String> cleanPatterns(List<String> patterns) {
if (patterns == null || patterns.isEmpty()) {
return List.of();
}
return patterns.stream()
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.toList();
}
/**
* 将规则实体转为视图对象,还原 JSON 字段。
*
* @param rule 规则实体
* @return 规则视图对象
*/
private EmailBillSenderRuleVO toVO(EmailBillSenderRule rule) {
EmailBillSenderRuleVO vo = voConverter.toEmailBillSenderRuleVO(rule);
vo.setSenderPatterns(parseStringList(rule.getSenderPatterns()));
vo.setTagIds(parseLongList(rule.getTagIds()));
return vo;
}
/**
* 序列化对象为 JSON 字符串。
*
* @param value 待序列化对象,为空时返回 null
* @return JSON 字符串
*/
private String writeJson(Object value) {
if (value == null) {
return null;
}
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new BusinessException("序列化发件人规则失败:" + e.getMessage());
}
}
/**
* 解析 JSON 字符串数组。
*
* @param json JSON 字符串
* @return 字符串列表,解析失败或为空时返回空列表
*/
private List<String> parseStringList(String json) {
if (!StringUtils.hasText(json)) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {
});
} catch (JsonProcessingException e) {
log.warn("解析发件人匹配串失败:{}", e.getMessage());
return List.of();
}
}
/**
* 解析 JSON 数字数组。
*
* @param json JSON 字符串
* @return 数字列表,解析失败或为空时返回空列表
*/
private List<Long> parseLongList(String json) {
if (!StringUtils.hasText(json)) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<Long>>() {
});
} catch (JsonProcessingException e) {
log.warn("解析标签ID列表失败:{}", e.getMessage());
return List.of();
}
}
}
@@ -39,7 +39,8 @@ import cn.rosercode.finance.vo.TrendStatisticsVO;
* 统计服务实现类,提供月度收支汇总、分类占比分析、趋势统计、概览指标及通用分组统计。
*
* <p>所有统计均基于 SQL 聚合(MyBatis-Plus {@code selectMaps})完成,避免将整段区间的
* 交易记录加载到内存;仅统计计入统计({@code include_in_stats = 1 或 NULL})的记录。</p>
* 交易记录加载到内存;统计口径由 {@code statsScope} 控制,默认仅统计计入统计
* ({@code include_in_stats = 1 或 NULL})的记录。</p>
*/
@Service
public class StatisticsServiceImpl implements StatisticsService {
@@ -280,8 +281,16 @@ public class StatisticsServiceImpl implements StatisticsService {
}
}
// 仅统计计入统计的记录(排除借款、账户间转账等)
wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats"));
// 统计口径:默认仅统计计入统计的记录(排除借款、账户间转账等)
String statsScope = query == null ? null : query.getStatsScope();
if ("ALL".equalsIgnoreCase(statsScope)) {
// 全部记录,不追加计入统计过滤
} else if ("EXCLUDED".equalsIgnoreCase(statsScope)) {
// 仅不计入统计的记录(NULL 视为计入,故此处只取 false)
wrapper.eq("include_in_stats", false);
} else {
wrapper.and(w -> w.eq("include_in_stats", true).or().isNull("include_in_stats"));
}
return wrapper;
}
@@ -296,7 +296,7 @@ public class TransactionServiceImpl implements TransactionService {
}
transaction.setIncludeInStats(includeInStats == null ? Boolean.TRUE : includeInStats);
transactionMapper.updateById(transaction);
log.info("切换计入统计标记: userId={}, id={}, includeInStats={}", userId, id, transaction.getIncludeInStats());
log.debug("切换计入统计标记: userId={}, id={}, includeInStats={}", userId, id, transaction.getIncludeInStats());
}
/**
@@ -313,7 +313,7 @@ public class TransactionServiceImpl implements TransactionService {
}
transaction.setRemarkHidden(remarkHidden == null ? Boolean.FALSE : remarkHidden);
transactionMapper.updateById(transaction);
log.info("切换备注隐藏标记: userId={}, id={}, remarkHidden={}", userId, id, transaction.getRemarkHidden());
log.debug("切换备注隐藏标记: userId={}, id={}, remarkHidden={}", userId, id, transaction.getRemarkHidden());
}
/**
@@ -0,0 +1,75 @@
package cn.rosercode.finance.vo;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/**
* 邮箱账单配置视图对象,用于前端展示 IMAP 配置(授权码已脱敏)。
*
* @date 2026-09-19 16:40:42
*/
@Data
public class EmailBillConfigVO {
/** 配置ID */
private Long id;
/** 配置名称 */
private String name;
/** IMAP 服务器地址 */
private String host;
/** IMAP 端口 */
private Integer port;
/** 是否使用 SSL */
private Boolean ssl;
/** 邮箱账号 */
private String username;
/** 是否已配置授权码 */
private Boolean hasPassword;
/** 监听的邮件文件夹 */
private String folder;
/** 是否启用监听 */
private Boolean enabled;
/** 连接状态 */
private String status;
/** 最近一次失败原因 */
private String lastError;
/** 最近一次成功连接时间 */
private LocalDateTime lastConnectedAt;
/** 已处理的最大邮件 UID */
private Long lastUid;
/** 默认支出分类ID */
private Long expenseCategoryId;
/** 默认收入分类ID */
private Long incomeCategoryId;
/** 默认标签ID列表 */
private List<Long> tagIds;
/** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封 */
private String fetchScope;
/** 拉取范围数量:DAYS 为天数,COUNT 为封数 */
private Integer fetchLimit;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,47 @@
package cn.rosercode.finance.vo;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 邮箱账单消息视图对象,用于前端展示拉取到的待处理账单附件。
*
* @date 2026-09-19 16:48:39
*/
@Data
public class EmailBillMessageVO {
/** 消息ID */
private Long id;
/** 邮箱配置ID */
private Long configId;
/** 邮件 IMAP UID */
private Long uid;
/** 邮件主题 */
private String subject;
/** 发件人地址 */
private String fromAddress;
/** 邮件接收时间 */
private LocalDateTime receivedAt;
/** 附件文件名 */
private String attachmentName;
/** 命中的账单类型 */
private String billType;
/** 处理状态:PULLED/CONFIRMED/IGNORED */
private String status;
/** 解析失败原因 */
private String error;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,51 @@
package cn.rosercode.finance.vo;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/**
* 邮箱账单发件人规则视图对象,用于前端展示发件人识别规则。
*
* @date 2026-09-19 16:40:44
*/
@Data
public class EmailBillSenderRuleVO {
/** 规则ID */
private Long id;
/** 规则名称 */
private String name;
/** 是否启用 */
private Boolean enabled;
/** 优先级,数值越小越优先 */
private Integer sortOrder;
/** 发件人匹配串列表 */
private List<String> senderPatterns;
/** 命中的账单类型 */
private String billType;
/** 该来源账单压缩包解压密码 */
private String archivePassword;
/** 默认支出分类ID */
private Long expenseCategoryId;
/** 默认收入分类ID */
private Long incomeCategoryId;
/** 默认标签ID列表 */
private List<Long> tagIds;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
+88 -21
View File
@@ -1,22 +1,22 @@
-- 用户表:存储系统用户的基本信息和登录凭证
CREATE TABLE IF NOT EXISTS t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一标识,用于登录',
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值,使用BCrypt加密存储',
nickname VARCHAR(50) COMMENT '用户昵称,显示名称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
nickname VARCHAR(50) COMMENT '用户昵称,显示名称'
);
-- 分类表:存储收支分类信息,支持用户自定义
CREATE TABLE IF NOT EXISTS t_category (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分类ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '分类名称',
type VARCHAR(10) NOT NULL COMMENT '分类类型:INCOME-收入,EXPENSE-支出',
icon VARCHAR(50) COMMENT '分类图标,可选',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
INDEX idx_user_type (user_id, type) COMMENT '用户ID+类型联合索引'
);
@@ -24,6 +24,8 @@ CREATE TABLE IF NOT EXISTS t_category (
-- 收支记录表:存储每一笔收入或支出的详细信息
CREATE TABLE IF NOT EXISTS t_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
category_id BIGINT COMMENT '所属分类ID,可为空表示未分类',
type VARCHAR(10) NOT NULL COMMENT '记录类型:INCOME-收入,EXPENSE-支出',
@@ -35,8 +37,6 @@ CREATE TABLE IF NOT EXISTS t_transaction (
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
raw_data JSON COMMENT '账单导入的原始完整数据(结构化JSON),手动记录为空',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
INDEX idx_category_id (category_id) COMMENT '分类ID索引',
INDEX idx_transaction_time (transaction_time) COMMENT '交易时间索引',
@@ -47,11 +47,11 @@ CREATE TABLE IF NOT EXISTS t_transaction (
-- 标签表:存储用户自定义标签,用于给交易打标签
CREATE TABLE IF NOT EXISTS t_tag (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '标签ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '标签名称',
color VARCHAR(20) COMMENT '标签颜色,用于前端展示',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id) COMMENT '用户ID索引',
UNIQUE INDEX uk_user_name (user_id, name) COMMENT '用户ID+标签名唯一索引'
);
@@ -59,9 +59,9 @@ CREATE TABLE IF NOT EXISTS t_tag (
-- 交易标签关联表:交易与标签的多对多关系
CREATE TABLE IF NOT EXISTS t_transaction_tag (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
transaction_id BIGINT NOT NULL COMMENT '交易记录ID',
tag_id BIGINT NOT NULL COMMENT '标签ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE INDEX uk_transaction_tag (transaction_id, tag_id) COMMENT '交易+标签唯一索引',
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
);
@@ -69,19 +69,20 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
-- AI 配置表:存储每个用户的 AI 智能建议相关配置
CREATE TABLE IF NOT EXISTS t_ai_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用AI智能建议:1-启用,0-关闭',
api_key VARCHAR(255) COMMENT 'DeepSeek API Key(明文存储)',
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com' COMMENT 'API 基础地址',
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' COMMENT '使用的模型名称',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX uk_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
);
-- AI 使用记录表:记录每次调用 DeepSeek 的用量与结果
CREATE TABLE IF NOT EXISTS t_ai_usage (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
type VARCHAR(20) NOT NULL COMMENT '调用类型:SUGGEST-生成建议,TEST-测试连接',
model VARCHAR(50) COMMENT '模型名称',
@@ -93,13 +94,14 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
error_message VARCHAR(500) COMMENT '失败原因',
duration_ms BIGINT COMMENT '调用耗时(毫秒)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
-- 通知配置表:存储每个用户的账单导入提醒设置
CREATE TABLE IF NOT EXISTS t_notification_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用定时提醒:1-启用,0-关闭',
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY' COMMENT '提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月',
@@ -107,39 +109,38 @@ CREATE TABLE IF NOT EXISTS t_notification_config (
day_of_week INT NOT NULL DEFAULT 1 COMMENT '每周提醒日(1-7,1为周一),frequency=WEEKLY 时生效',
send_time VARCHAR(5) NOT NULL DEFAULT '09:00' COMMENT '提醒时间(HH:mm)',
last_sent_at DATETIME COMMENT '最近一次发送时间,用于避免重复提醒',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX uk_notification_config_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
);
-- 通知渠道表:存储用户配置的邮箱、Server 酱等推送渠道
CREATE TABLE IF NOT EXISTS t_notification_channel (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '渠道ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱',
name VARCHAR(50) COMMENT '渠道名称,便于用户识别',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-关闭',
config TEXT COMMENT '渠道配置(JSON),如 webhook 地址、SMTP 账号等',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_notification_channel_user_id (user_id) COMMENT '用户ID索引'
);
-- 通知发送日志表:记录每次提醒的发送结果,便于排查问题
CREATE TABLE IF NOT EXISTS t_notification_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
channel_id BIGINT COMMENT '渠道ID,测试或渠道已删除时可能为空',
channel_type VARCHAR(20) COMMENT '渠道类型',
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
message VARCHAR(500) COMMENT '结果描述或失败原因',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_notification_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
-- HTTP 访问日志表:记录每次接口请求的方法、路径、状态与耗时,便于追溯接口调用
CREATE TABLE IF NOT EXISTS t_http_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT COMMENT '用户ID,未登录或认证失败时为空',
method VARCHAR(10) COMMENT '请求方法:GET/POST/PUT/DELETE 等',
path VARCHAR(255) COMMENT '请求路径',
@@ -148,7 +149,6 @@ CREATE TABLE IF NOT EXISTS t_http_log (
duration_ms BIGINT COMMENT '处理耗时(毫秒)',
ip VARCHAR(64) COMMENT '客户端IP',
user_agent VARCHAR(500) COMMENT 'User-Agent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_http_log_created (created_at) COMMENT '创建时间索引',
INDEX idx_http_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
@@ -156,13 +156,13 @@ CREATE TABLE IF NOT EXISTS t_http_log (
-- 登录日志表:记录每次登录尝试的结果,便于审计与排查
CREATE TABLE IF NOT EXISTS t_login_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
user_id BIGINT COMMENT '用户ID,登录失败或用户不存在时为空',
username VARCHAR(50) COMMENT '登录用户名',
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
message VARCHAR(500) COMMENT '结果描述或失败原因',
ip VARCHAR(64) COMMENT '客户端IP',
user_agent VARCHAR(500) COMMENT 'User-Agent',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_login_log_created (created_at) COMMENT '创建时间索引',
INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
@@ -170,6 +170,8 @@ CREATE TABLE IF NOT EXISTS t_login_log (
-- 自定义分类与标签规则表:按关键词、收支类型、金额区间自动归类与打标签
CREATE TABLE IF NOT EXISTS t_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '规则名称',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-停用',
@@ -179,17 +181,82 @@ CREATE TABLE IF NOT EXISTS t_rule (
min_amount DECIMAL(12, 2) COMMENT '金额下限(含),可为空',
max_amount DECIMAL(12, 2) COMMENT '金额上限(含),可为空',
category_id BIGINT COMMENT '命中的目标分类ID,可为空',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_rule_user_id (user_id) COMMENT '用户ID索引'
);
-- 规则标签关联表:规则与标签的多对多关系
CREATE TABLE IF NOT EXISTS t_rule_tag (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
rule_id BIGINT NOT NULL COMMENT '规则ID',
tag_id BIGINT NOT NULL COMMENT '标签ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE INDEX uk_rule_tag (rule_id, tag_id) COMMENT '规则+标签唯一索引',
INDEX idx_rule_tag_tag_id (tag_id) COMMENT '标签ID索引'
);
-- 邮箱账单配置表:存储用户用于自动拉取账单附件的 IMAP 邮箱连接信息
CREATE TABLE IF NOT EXISTS t_email_bill_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '配置名称,便于用户识别',
host VARCHAR(255) NOT NULL COMMENT 'IMAP 服务器地址',
port INT NOT NULL DEFAULT 993 COMMENT 'IMAP 端口',
use_ssl TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否使用 SSL:1-是,0-否',
username VARCHAR(255) NOT NULL COMMENT '邮箱账号',
password VARCHAR(255) NOT NULL COMMENT '邮箱授权码(明文存储,前端脱敏返回)',
folder VARCHAR(100) NOT NULL DEFAULT 'INBOX' COMMENT '监听的邮件文件夹',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用监听:1-启用,0-关闭',
status VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED' COMMENT '连接状态:DISCONNECTED-未连接,CONNECTED-已连接,POLLING-轮询中,ERROR-异常',
last_error VARCHAR(500) COMMENT '最近一次连接或拉取失败原因',
last_connected_at DATETIME COMMENT '最近一次成功连接时间',
last_uid BIGINT COMMENT '已处理的最大邮件 UID,用于增量拉取',
expense_category_id BIGINT COMMENT '默认支出分类ID,可为空',
income_category_id BIGINT COMMENT '默认收入分类ID,可为空',
tag_ids TEXT COMMENT '默认标签ID列表(JSON数组)',
fetch_scope VARCHAR(20) NOT NULL DEFAULT 'INCREMENT' COMMENT '拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封',
fetch_limit INT COMMENT '拉取范围数量:DAYS 为天数,COUNT 为封数',
INDEX idx_email_bill_config_user_id (user_id) COMMENT '用户ID索引'
);
-- 邮箱账单发件人规则表:按邮件发件人匹配账单类型与默认归类
CREATE TABLE IF NOT EXISTS t_email_bill_sender_rule (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
name VARCHAR(50) NOT NULL COMMENT '规则名称',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-停用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越小越优先',
sender_patterns TEXT COMMENT '发件人匹配串列表(JSON数组),包含匹配且忽略大小写',
bill_type VARCHAR(20) NOT NULL COMMENT '命中的账单类型:ALIPAY/WECHAT/MEITUAN/CMB/BOCOM',
archive_password VARCHAR(100) COMMENT '该来源账单压缩包解压密码,可为空',
expense_category_id BIGINT COMMENT '默认支出分类ID,可为空',
income_category_id BIGINT COMMENT '默认收入分类ID,可为空',
tag_ids TEXT COMMENT '默认标签ID列表(JSON数组)',
INDEX idx_email_bill_sender_rule_user_id (user_id) COMMENT '用户ID索引'
);
-- 邮箱账单消息表:记录拉取到的账单附件,解析与入库由用户手动触发
CREATE TABLE IF NOT EXISTS t_email_bill_message (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
config_id BIGINT NOT NULL COMMENT '邮箱配置ID',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
uid BIGINT NOT NULL COMMENT '邮件 IMAP UID',
message_id VARCHAR(255) COMMENT '邮件 Message-ID',
subject VARCHAR(500) COMMENT '邮件主题',
from_address VARCHAR(255) COMMENT '发件人地址',
received_at DATETIME COMMENT '邮件接收时间',
attachment_index INT NOT NULL DEFAULT 0 COMMENT '附件在邮件中的序号(从0开始)',
attachment_name VARCHAR(255) COMMENT '附件文件名',
attachment_path VARCHAR(500) COMMENT '附件本地存储相对路径',
sender_rule_id BIGINT COMMENT '命中的发件人规则ID',
bill_type VARCHAR(20) COMMENT '命中的账单类型,未命中时为空',
status VARCHAR(20) NOT NULL DEFAULT 'PULLED' COMMENT '处理状态:PULLED-待解析,CONFIRMED-已导入,IGNORED-已忽略',
error VARCHAR(500) COMMENT '解析失败原因',
UNIQUE INDEX uk_email_bill_message (config_id, uid, attachment_index) COMMENT '配置+UID+附件序号唯一索引,用于去重',
INDEX idx_email_bill_message_user_status (user_id, status) COMMENT '用户ID+状态联合索引'
);
+122 -34
View File
@@ -9,27 +9,29 @@
CREATE TABLE IF NOT EXISTS t_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(50),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
nickname VARCHAR(50)
);;
CREATE TABLE IF NOT EXISTS t_category (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
type VARCHAR(10) NOT NULL,
icon VARCHAR(50),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
sort_order INTEGER NOT NULL DEFAULT 0
);;
CREATE INDEX IF NOT EXISTS idx_category_user_id ON t_category (user_id);;
CREATE INDEX IF NOT EXISTS idx_category_user_type ON t_category (user_id, type);;
CREATE TABLE IF NOT EXISTS t_transaction (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
category_id INTEGER,
type VARCHAR(10) NOT NULL,
@@ -40,9 +42,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
external_no VARCHAR(64),
transaction_time DATETIME NOT NULL,
include_in_stats INTEGER NOT NULL DEFAULT 1,
raw_data TEXT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
raw_data TEXT
);;
CREATE INDEX IF NOT EXISTS idx_transaction_user_id ON t_transaction (user_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_category_id ON t_transaction (category_id);;
@@ -52,38 +52,39 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_user_external ON t_transaction (user_id, ex
CREATE TABLE IF NOT EXISTS t_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
color VARCHAR(20),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
sort_order INTEGER NOT NULL DEFAULT 0
);;
CREATE INDEX IF NOT EXISTS idx_tag_user_id ON t_tag (user_id);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_tag_user_name ON t_tag (user_id, name);;
CREATE TABLE IF NOT EXISTS t_transaction_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
transaction_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
tag_id INTEGER NOT NULL
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);;
CREATE TABLE IF NOT EXISTS t_ai_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
api_key VARCHAR(255),
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com',
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat',
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat'
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_ai_config_user_id ON t_ai_config (user_id);;
CREATE TABLE IF NOT EXISTS t_ai_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
model VARCHAR(50),
@@ -94,8 +95,7 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
cached_tokens INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 1,
error_message VARCHAR(500),
duration_ms INTEGER,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
duration_ms INTEGER
);;
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
@@ -126,38 +126,38 @@ END;;
CREATE TABLE IF NOT EXISTS t_notification_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY',
day_of_month INTEGER NOT NULL DEFAULT 1,
day_of_week INTEGER NOT NULL DEFAULT 1,
send_time VARCHAR(5) NOT NULL DEFAULT '09:00',
last_sent_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
last_sent_at DATETIME
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_notification_config_user_id ON t_notification_config (user_id);;
CREATE TABLE IF NOT EXISTS t_notification_channel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
name VARCHAR(50),
enabled INTEGER NOT NULL DEFAULT 1,
config TEXT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
config TEXT
);;
CREATE INDEX IF NOT EXISTS idx_notification_channel_user_id ON t_notification_channel (user_id);;
CREATE TABLE IF NOT EXISTS t_notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
channel_id INTEGER,
channel_type VARCHAR(20),
success INTEGER NOT NULL DEFAULT 1,
message VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
message VARCHAR(500)
);;
CREATE INDEX IF NOT EXISTS idx_notification_log_user_created ON t_notification_log (user_id, created_at);;
@@ -179,6 +179,7 @@ END;;
CREATE TABLE IF NOT EXISTS t_http_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER,
method VARCHAR(10),
path VARCHAR(255),
@@ -186,27 +187,28 @@ CREATE TABLE IF NOT EXISTS t_http_log (
status INTEGER,
duration_ms INTEGER,
ip VARCHAR(64),
user_agent VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
user_agent VARCHAR(500)
);;
CREATE INDEX IF NOT EXISTS idx_http_log_created ON t_http_log (created_at);;
CREATE INDEX IF NOT EXISTS idx_http_log_user_created ON t_http_log (user_id, created_at);;
CREATE TABLE IF NOT EXISTS t_login_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER,
username VARCHAR(50),
success INTEGER NOT NULL DEFAULT 1,
message VARCHAR(500),
ip VARCHAR(64),
user_agent VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
user_agent VARCHAR(500)
);;
CREATE INDEX IF NOT EXISTS idx_login_log_created ON t_login_log (created_at);;
CREATE INDEX IF NOT EXISTS idx_login_log_user_created ON t_login_log (user_id, created_at);;
CREATE TABLE IF NOT EXISTS t_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
@@ -215,17 +217,15 @@ CREATE TABLE IF NOT EXISTS t_rule (
keyword VARCHAR(100),
min_amount NUMERIC(12, 2),
max_amount NUMERIC(12, 2),
category_id INTEGER,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
category_id INTEGER
);;
CREATE INDEX IF NOT EXISTS idx_rule_user_id ON t_rule (user_id);;
CREATE TABLE IF NOT EXISTS t_rule_tag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
rule_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
tag_id INTEGER NOT NULL
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_rule_tag ON t_rule_tag (rule_id, tag_id);;
CREATE INDEX IF NOT EXISTS idx_rule_tag_tag_id ON t_rule_tag (tag_id);;
@@ -237,3 +237,91 @@ WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_rule SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TABLE IF NOT EXISTS t_email_bill_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL DEFAULT 993,
use_ssl INTEGER NOT NULL DEFAULT 1,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
folder VARCHAR(100) NOT NULL DEFAULT 'INBOX',
enabled INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'DISCONNECTED',
last_error VARCHAR(500),
last_connected_at DATETIME,
last_uid INTEGER,
expense_category_id INTEGER,
income_category_id INTEGER,
tag_ids TEXT,
fetch_scope VARCHAR(20) NOT NULL DEFAULT 'INCREMENT',
fetch_limit INTEGER
);;
CREATE INDEX IF NOT EXISTS idx_email_bill_config_user_id ON t_email_bill_config (user_id);;
CREATE TABLE IF NOT EXISTS t_email_bill_sender_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
user_id INTEGER NOT NULL,
name VARCHAR(50) NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
sender_patterns TEXT,
bill_type VARCHAR(20) NOT NULL,
archive_password VARCHAR(100),
expense_category_id INTEGER,
income_category_id INTEGER,
tag_ids TEXT
);;
CREATE INDEX IF NOT EXISTS idx_email_bill_sender_rule_user_id ON t_email_bill_sender_rule (user_id);;
CREATE TABLE IF NOT EXISTS t_email_bill_message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
config_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
uid INTEGER NOT NULL,
message_id VARCHAR(255),
subject VARCHAR(500),
from_address VARCHAR(255),
received_at DATETIME,
attachment_index INTEGER NOT NULL DEFAULT 0,
attachment_name VARCHAR(255),
attachment_path VARCHAR(500),
sender_rule_id INTEGER,
bill_type VARCHAR(20),
status VARCHAR(20) NOT NULL DEFAULT 'PULLED',
error VARCHAR(500)
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_email_bill_message ON t_email_bill_message (config_id, uid, attachment_index);;
CREATE INDEX IF NOT EXISTS idx_email_bill_message_user_status ON t_email_bill_message (user_id, status);;
CREATE TRIGGER IF NOT EXISTS trg_email_bill_config_updated_at
AFTER UPDATE ON t_email_bill_config
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_email_bill_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_email_bill_sender_rule_updated_at
AFTER UPDATE ON t_email_bill_sender_rule
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_email_bill_sender_rule SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_email_bill_message_updated_at
AFTER UPDATE ON t_email_bill_message
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_email_bill_message SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;