>() {
+ });
+ } catch (JsonProcessingException e) {
+ log.warn("解析发件人匹配串失败:{}", e.getMessage());
+ return List.of();
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java b/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java
new file mode 100644
index 0000000..8542c91
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/email/EmailBillStorage.java
@@ -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;
+
+/**
+ * 邮箱账单附件本地存储。
+ *
+ * 附件保存在工作目录下的 {@code data/email-bill/{configId}/{uid}/{index}_{name}},
+ * 数据库仅记录相对路径;导入或忽略后不自动删除,便于追溯与重新解析。
+ *
+ * @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("[\\\\/:*?\"<>|]", "_");
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java b/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java
new file mode 100644
index 0000000..a35292d
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/email/EmailImapConnector.java
@@ -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 连接。
+ *
+ * QQ、163 等邮箱通常使用 993 端口并开启 SSL;超时时间设置较短,
+ * 避免监听线程在断网时长时间阻塞。
+ *
+ * @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;
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/AiConfig.java b/src/main/java/cn/rosercode/finance/entity/AiConfig.java
index f11a585..1936edd 100644
--- a/src/main/java/cn/rosercode/finance/entity/AiConfig.java
+++ b/src/main/java/cn/rosercode/finance/entity/AiConfig.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/AiUsage.java b/src/main/java/cn/rosercode/finance/entity/AiUsage.java
index 9ab0b27..6a0658d 100644
--- a/src/main/java/cn/rosercode/finance/entity/AiUsage.java
+++ b/src/main/java/cn/rosercode/finance/entity/AiUsage.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/Category.java b/src/main/java/cn/rosercode/finance/entity/Category.java
index 2747dd5..e4ba68d 100644
--- a/src/main/java/cn/rosercode/finance/entity/Category.java
+++ b/src/main/java/cn/rosercode/finance/entity/Category.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java b/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java
new file mode 100644
index 0000000..9774b6b
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/EmailBillConfig.java
@@ -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。
+ *
+ * 保存用于自动拉取账单附件的 IMAP 邮箱连接信息,以及默认分类与标签;
+ * 每个配置对应一个后台监听线程。
+ *
+ * @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;
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java b/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java
new file mode 100644
index 0000000..f1db0fe
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/EmailBillMessage.java
@@ -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。
+ *
+ * 记录从邮箱拉取到的账单附件(仅保存附件到本地并登记元信息),
+ * 解析与入库均由用户在前端手动触发,故不持久化解析结果。
+ *
+ * @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;
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java b/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java
new file mode 100644
index 0000000..c1b8443
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/EmailBillSenderRule.java
@@ -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。
+ *
+ * 按邮件发件人匹配账单类型与默认归类:发件人匹配串命中后,即确定该附件所属的
+ * 账单类型、压缩包解压密码以及默认分类与标签。
+ *
+ * @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;
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/HttpLog.java b/src/main/java/cn/rosercode/finance/entity/HttpLog.java
index 10fe1ed..05fee88 100644
--- a/src/main/java/cn/rosercode/finance/entity/HttpLog.java
+++ b/src/main/java/cn/rosercode/finance/entity/HttpLog.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/LoginLog.java b/src/main/java/cn/rosercode/finance/entity/LoginLog.java
index 0ad5981..31c55b5 100644
--- a/src/main/java/cn/rosercode/finance/entity/LoginLog.java
+++ b/src/main/java/cn/rosercode/finance/entity/LoginLog.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java b/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java
index 312d2c6..cfe5a33 100644
--- a/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java
+++ b/src/main/java/cn/rosercode/finance/entity/NotificationChannel.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java b/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java
index 1913512..64357ef 100644
--- a/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java
+++ b/src/main/java/cn/rosercode/finance/entity/NotificationConfig.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/NotificationLog.java b/src/main/java/cn/rosercode/finance/entity/NotificationLog.java
index 2169282..b6729e1 100644
--- a/src/main/java/cn/rosercode/finance/entity/NotificationLog.java
+++ b/src/main/java/cn/rosercode/finance/entity/NotificationLog.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/Rule.java b/src/main/java/cn/rosercode/finance/entity/Rule.java
index 9dafddb..c3b5820 100644
--- a/src/main/java/cn/rosercode/finance/entity/Rule.java
+++ b/src/main/java/cn/rosercode/finance/entity/Rule.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/RuleTag.java b/src/main/java/cn/rosercode/finance/entity/RuleTag.java
index 5caf948..f3aa8fb 100644
--- a/src/main/java/cn/rosercode/finance/entity/RuleTag.java
+++ b/src/main/java/cn/rosercode/finance/entity/RuleTag.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/Tag.java b/src/main/java/cn/rosercode/finance/entity/Tag.java
index d0378fd..dce85a7 100644
--- a/src/main/java/cn/rosercode/finance/entity/Tag.java
+++ b/src/main/java/cn/rosercode/finance/entity/Tag.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/Transaction.java b/src/main/java/cn/rosercode/finance/entity/Transaction.java
index 75b5453..432409a 100644
--- a/src/main/java/cn/rosercode/finance/entity/Transaction.java
+++ b/src/main/java/cn/rosercode/finance/entity/Transaction.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/TransactionTag.java b/src/main/java/cn/rosercode/finance/entity/TransactionTag.java
index 6846541..91a93b0 100644
--- a/src/main/java/cn/rosercode/finance/entity/TransactionTag.java
+++ b/src/main/java/cn/rosercode/finance/entity/TransactionTag.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/User.java b/src/main/java/cn/rosercode/finance/entity/User.java
index 05a12de..59e135d 100644
--- a/src/main/java/cn/rosercode/finance/entity/User.java
+++ b/src/main/java/cn/rosercode/finance/entity/User.java
@@ -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;
}
diff --git a/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java b/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java
new file mode 100644
index 0000000..2691bcc
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/enums/EmailBillStatus.java
@@ -0,0 +1,37 @@
+package cn.rosercode.finance.enums;
+
+/**
+ * 邮箱账单消息处理状态枚举。
+ *
+ * 拉取到的账单附件默认处于 {@link #PULLED}(待解析),用户手动解析并导入后置为
+ * {@link #CONFIRMED},用户主动忽略后置为 {@link #IGNORED}。
+ *
+ * @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;
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java
new file mode 100644
index 0000000..c8b8481
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillConfigMapper.java
@@ -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 {
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java
new file mode 100644
index 0000000..beb2faf
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillMessageMapper.java
@@ -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 {
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java b/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java
new file mode 100644
index 0000000..576d051
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/EmailBillSenderRuleMapper.java
@@ -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 {
+}
diff --git a/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java b/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java
new file mode 100644
index 0000000..8e362ef
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/scheduler/EmailBillScheduler.java
@@ -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;
+
+/**
+ * 邮箱账单调度器。
+ *
+ * 定期同步监听线程(保证启用配置都有常驻监听),并对所有启用配置执行兜底增量检查,
+ * 防止 IDLE 漏事件导致账单邮件未被拉取。
+ *
+ * @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 configs;
+ try {
+ configs = configMapper.selectList(new LambdaQueryWrapper()
+ .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());
+ }
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java b/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java
new file mode 100644
index 0000000..e556485
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillCheckStreamService.java
@@ -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;
+
+/**
+ * 邮箱账单立即检查的实时进度推送服务。
+ *
+ * 基于 SSE 将「立即检查」的执行进度实时推送给前端:先发送 {@code meta} 事件,
+ * 随后逐行推送 {@code line} 事件,正常结束发送 {@code done}(含新增待处理条数),
+ * 异常时发送 {@code failed}(含失败原因)。同一配置同时只允许一个检查任务。
+ *
+ * @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 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", " ");
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java
new file mode 100644
index 0000000..29d5186
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillConfigService.java
@@ -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 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 listEnabledConfigs();
+}
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java b/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java
new file mode 100644
index 0000000..db32fe8
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillMessageService.java
@@ -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 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);
+}
diff --git a/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java
new file mode 100644
index 0000000..f256e64
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/EmailBillSenderRuleService.java
@@ -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 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 listEnabledRules(Long userId);
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java b/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java
index 4fa79d5..fc8d4ae 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/DefaultDataInitializer.java
@@ -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;
/**
* 默认数据初始化器。
*
- * 为新注册用户创建一套默认分类与标签。相比原先需要手动执行的 SQL 种子脚本,
+ *
为新注册用户创建一套默认分类、标签与邮箱账单发件人规则。相比原先需要手动执行的 SQL 种子脚本,
* 使用 Java 代码插入可同时兼容 MySQL 与 SQLite 两种数据库。
*
* @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 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 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 patterns, String billType, int sortOrder) {
+ }
}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java
new file mode 100644
index 0000000..745922f
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillConfigServiceImpl.java
@@ -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 邮箱配置的增删改查、启停与连接测试。
+ *
+ * 授权码明文持久化但不在视图对象中返回;测试连接成功或失败都会回写连接状态,
+ * 便于前端直接展示邮箱的可用性。
+ *
+ * @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 list(Long userId) {
+ List configs = configMapper.selectList(new LambdaQueryWrapper()
+ .eq(EmailBillConfig::getUserId, userId)
+ .orderByAsc(EmailBillConfig::getId));
+ List 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()
+ .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 listEnabledConfigs() {
+ return configMapper.selectList(new LambdaQueryWrapper()
+ .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 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 validTagIds(List 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 parseLongList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {
+ });
+ } 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);
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java
new file mode 100644
index 0000000..1ef18e5
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillMessageServiceImpl.java
@@ -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;
+
+/**
+ * 邮箱账单消息服务实现类,实现待处理列表与「解析、选择、入库」三步流程。
+ *
+ * 解析阶段读取本地附件并复用账单导入服务,返回预览行但不落库;
+ * 导入阶段复用账单导入服务写入交易记录,成功后标记消息为已导入。
+ *
+ * @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 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 wrapper = new LambdaQueryWrapper()
+ .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 messages = messageMapper.selectList(wrapper
+ .orderByDesc(EmailBillMessage::getId)
+ .last("LIMIT " + safeSize + " OFFSET " + offset));
+
+ List 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()
+ .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);
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java
new file mode 100644
index 0000000..3ccb049
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/EmailBillSenderRuleServiceImpl.java
@@ -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;
+
+/**
+ * 邮箱账单发件人规则服务实现类,处理发件人识别规则的增删改查与匹配数据准备。
+ *
+ * 发件人匹配串与默认标签以 JSON 数组持久化,视图对象中还原为列表;
+ * 匹配本身在邮件拉取时由 {@code EmailBillProcessor} 按优先级完成。
+ *
+ * @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 list(Long userId) {
+ List rules = senderRuleMapper.selectList(new LambdaQueryWrapper()
+ .eq(EmailBillSenderRule::getUserId, userId)
+ .orderByAsc(EmailBillSenderRule::getSortOrder)
+ .orderByAsc(EmailBillSenderRule::getId));
+ List 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 listEnabledRules(Long userId) {
+ return senderRuleMapper.selectList(new LambdaQueryWrapper()
+ .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 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 validTagIds(List tagIds) {
+ if (tagIds == null || tagIds.isEmpty()) {
+ return List.of();
+ }
+ return tagMapper.selectBatchIds(tagIds).stream()
+ .map(Tag::getId)
+ .toList();
+ }
+
+ /**
+ * 清理发件人匹配串:去空白、去空项、去重。
+ *
+ * @param patterns 原始匹配串列表
+ * @return 清理后的匹配串列表
+ */
+ private List cleanPatterns(List 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 parseStringList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {
+ });
+ } catch (JsonProcessingException e) {
+ log.warn("解析发件人匹配串失败:{}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ /**
+ * 解析 JSON 数字数组。
+ *
+ * @param json JSON 字符串
+ * @return 数字列表,解析失败或为空时返回空列表
+ */
+ private List parseLongList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {
+ });
+ } catch (JsonProcessingException e) {
+ log.warn("解析标签ID列表失败:{}", e.getMessage());
+ return List.of();
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
index 5cc57ae..3604241 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
@@ -39,7 +39,8 @@ import cn.rosercode.finance.vo.TrendStatisticsVO;
* 统计服务实现类,提供月度收支汇总、分类占比分析、趋势统计、概览指标及通用分组统计。
*
* 所有统计均基于 SQL 聚合(MyBatis-Plus {@code selectMaps})完成,避免将整段区间的
- * 交易记录加载到内存;仅统计计入统计({@code include_in_stats = 1 或 NULL})的记录。
+ * 交易记录加载到内存;统计口径由 {@code statsScope} 控制,默认仅统计计入统计
+ * ({@code include_in_stats = 1 或 NULL})的记录。
*/
@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;
}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
index 49883b2..71c9855 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
@@ -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());
}
/**
diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java
new file mode 100644
index 0000000..0803e6e
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/EmailBillConfigVO.java
@@ -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 tagIds;
+
+ /** 拉取范围:INCREMENT-仅新邮件,DAYS-最近N天,COUNT-最近N封 */
+ private String fetchScope;
+
+ /** 拉取范围数量:DAYS 为天数,COUNT 为封数 */
+ private Integer fetchLimit;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ /** 更新时间 */
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java
new file mode 100644
index 0000000..4f0842f
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/EmailBillMessageVO.java
@@ -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;
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java b/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java
new file mode 100644
index 0000000..6127473
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/EmailBillSenderRuleVO.java
@@ -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 senderPatterns;
+
+ /** 命中的账单类型 */
+ private String billType;
+
+ /** 该来源账单压缩包解压密码 */
+ private String archivePassword;
+
+ /** 默认支出分类ID */
+ private Long expenseCategoryId;
+
+ /** 默认收入分类ID */
+ private Long incomeCategoryId;
+
+ /** 默认标签ID列表 */
+ private List tagIds;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ /** 更新时间 */
+ private LocalDateTime updatedAt;
+}
diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql
index 1497d94..6de13eb 100644
--- a/src/main/resources/sql/init.sql
+++ b/src/main/resources/sql/init.sql
@@ -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+状态联合索引'
+);
diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql
index 734c23e..6814e5e 100644
--- a/src/main/resources/sql/schema-sqlite.sql
+++ b/src/main/resources/sql/schema-sqlite.sql
@@ -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;;