feat: 邮箱账单自动拉取
- 新增邮箱配置、发件人规则、待处理账单三张表(SQLite/MySQL 双脚本)及 CRUD 接口 - 按邮件发件人匹配账单类型,未命中忽略;发件人规则可配置解压密码与默认分类/标签 - 支持 IMAP IDLE 实时监听、定时兜底与手动立即检查;拉取范围可配置(仅新邮件/最近 N 天/最近 N 封) - 附件保存至本地 data/email-bill,解析预览后手动导入,复用账单解析与规则引擎 - 立即检查通过 SSE 实时推送执行进度 - 前端新增「邮箱账单」页(邮箱配置/发件人规则/待处理账单三 Tab),支持子路由与 Tab 偏好记忆,配置弹窗提供常用 IMAP 服务器候选项 - 新用户注册自动初始化默认发件人规则(支付宝/微信/美团/招商银行/交通银行) - 抽取共用账单预览与结果组件,供上传导入与邮箱账单复用 同时包含: - 统计新增口径筛选(仅计入/全部/仅不计入),支付方式改为后端动态获取 - 统一实体 createdAt/updatedAt 位置至 id 之后,调整部分日志级别
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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+状态联合索引'
|
||||
);
|
||||
|
||||
@@ -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;;
|
||||
|
||||
Reference in New Issue
Block a user