diff --git a/Service/EmailFetchService.cs b/Service/EmailServices/EmailFetchService.cs
similarity index 98%
rename from Service/EmailFetchService.cs
rename to Service/EmailServices/EmailFetchService.cs
index 040e97d..644144b 100644
--- a/Service/EmailFetchService.cs
+++ b/Service/EmailServices/EmailFetchService.cs
@@ -4,7 +4,7 @@ using MailKit.Search;
using MailKit.Security;
using MimeKit;
-namespace Service;
+namespace Service.EmailServices;
///
/// 邮件抓取服务接口
@@ -15,7 +15,7 @@ public interface IEmailFetchService
/// 连接状态
///
bool IsConnected { get; }
-
+
///
/// 连接到邮件服务器
///
@@ -30,7 +30,7 @@ public interface IEmailFetchService
/// 获取所有邮件
///
Task> FetchAllMessagesAsync();
-
+
///
/// 断开与邮件服务器的连接
///
@@ -40,7 +40,7 @@ public interface IEmailFetchService
/// 标记邮件为已读
///
Task MarkAsReadAsync(UniqueId uid);
-
+
///
/// 确保连接有效,如断开则自动重连
///
@@ -61,7 +61,7 @@ public class EmailFetchService(ILogger logger) : IEmailFetchS
private DateTime _lastKeepAlive = DateTime.MinValue;
private const int KeepAliveIntervalSeconds = 300; // 5分钟发送一次KeepAlive
private readonly ILogger _logger = logger;
-
+
public bool IsConnected => _imapClient?.IsConnected == true;
public async Task ConnectAsync(string host, int port, bool useSsl, string email, string password)
@@ -74,13 +74,13 @@ public class EmailFetchService(ILogger logger) : IEmailFetchS
_useSsl = useSsl;
_email = email;
_password = password;
-
+
// 如果已连接,先断开
if (_imapClient?.IsConnected == true)
{
await DisconnectAsync();
}
-
+
_imapClient = new ImapClient();
if (useSsl)
@@ -206,7 +206,7 @@ public class EmailFetchService(ILogger logger) : IEmailFetchS
// 标记邮件为已读(设置Seen标记)
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
-
+
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
}
catch (Exception ex)
@@ -214,7 +214,7 @@ public class EmailFetchService(ILogger logger) : IEmailFetchS
_logger.LogError(ex, "标记邮件为已读失败: {Message}", ex.Message);
}
}
-
+
///
/// 确保连接有效,如果断开则自动重连
///
@@ -240,13 +240,13 @@ public class EmailFetchService(ILogger logger) : IEmailFetchS
}
return _imapClient?.IsConnected == true;
}
-
+
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
{
_logger.LogWarning("未初始化连接信息,无法自动重连");
return false;
}
-
+
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
return await ConnectAsync(_host, _port, _useSsl, _email, _password);
}
diff --git a/Service/EmailHandleService.cs b/Service/EmailServices/EmailHandleService.cs
similarity index 99%
rename from Service/EmailHandleService.cs
rename to Service/EmailServices/EmailHandleService.cs
index b0b1fe5..89f0373 100644
--- a/Service/EmailHandleService.cs
+++ b/Service/EmailServices/EmailHandleService.cs
@@ -1,6 +1,6 @@
using Service.EmailParseServices;
-namespace Service;
+namespace Service.EmailServices;
public interface IEmailHandleService
{
diff --git a/Service/EmailParseServices/EmailParseForm95555.cs b/Service/EmailServices/EmailParse/EmailParseForm95555.cs
similarity index 100%
rename from Service/EmailParseServices/EmailParseForm95555.cs
rename to Service/EmailServices/EmailParse/EmailParseForm95555.cs
diff --git a/Service/EmailParseServices/EmailParseFormCCSVC.cs b/Service/EmailServices/EmailParse/EmailParseFormCCSVC.cs
similarity index 100%
rename from Service/EmailParseServices/EmailParseFormCCSVC.cs
rename to Service/EmailServices/EmailParse/EmailParseFormCCSVC.cs
diff --git a/Service/EmailParseServices/IEmailParseServices.cs b/Service/EmailServices/EmailParse/IEmailParseServices.cs
similarity index 92%
rename from Service/EmailParseServices/IEmailParseServices.cs
rename to Service/EmailServices/EmailParse/IEmailParseServices.cs
index ce66004..cd79c3d 100644
--- a/Service/EmailParseServices/IEmailParseServices.cs
+++ b/Service/EmailServices/EmailParse/IEmailParseServices.cs
@@ -72,17 +72,17 @@ public abstract class EmailParseServicesBase(
)[]?> ParseByAiAsync(string body)
{
var systemPrompt = $"""
- 你是一个信息抽取助手。
- 仅输出严格的JSON数组,不要包含任何多余文本。
- 每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串,yyyy-MM-dd HH:mm:ss格式日期时间)。
- 如果缺失,请推断或置空。
- [重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
- """;
+ 你是一个信息抽取助手。
+ 仅输出严格的JSON数组,不要包含任何多余文本。
+ 每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串,yyyy-MM-dd HH:mm:ss格式日期时间)。
+ 如果缺失,请推断或置空。
+ [重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
+ """;
var userPrompt = $"""
- 从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,
- 每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
- 正文如下:\n\n{body}
- """;
+ 从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,
+ 每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
+ 正文如下:\n\n{body}
+ """;
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
if (string.IsNullOrWhiteSpace(contentText))
diff --git a/Service/EmailBackgroundService.cs b/Service/EmailServices/EmailSyncService.cs
similarity index 97%
rename from Service/EmailBackgroundService.cs
rename to Service/EmailServices/EmailSyncService.cs
index 2c952a2..677a922 100644
--- a/Service/EmailBackgroundService.cs
+++ b/Service/EmailServices/EmailSyncService.cs
@@ -1,9 +1,9 @@
using System.ComponentModel;
using MimeKit;
-namespace Service;
+namespace Service.EmailServices;
-public interface IEmailBackgroundService
+public interface IEmailSyncService
{
///
/// 手动触发邮件同步
@@ -11,12 +11,12 @@ public interface IEmailBackgroundService
Task SyncEmailsAsync();
}
-public class EmailBackgroundService(
+public class EmailSyncService(
IOptions emailSettings,
IServiceProvider serviceProvider,
IEmailHandleService emailHandleService,
- ILogger logger)
- : BackgroundWorker, IEmailBackgroundService
+ ILogger logger)
+ : BackgroundWorker, IEmailSyncService
{
private readonly Dictionary _emailFetchServices = new();
private bool _isInitialized;
diff --git a/Service/ImportService.cs b/Service/ImportService.cs
index d3465a9..ceb1294 100644
--- a/Service/ImportService.cs
+++ b/Service/ImportService.cs
@@ -496,4 +496,3 @@ public class ImportService(
"yyyyMMddHHmmss",
];
}
-
diff --git a/Service/Jobs/EmailSyncJob.cs b/Service/Jobs/EmailSyncJob.cs
index a99b655..4af8b29 100644
--- a/Service/Jobs/EmailSyncJob.cs
+++ b/Service/Jobs/EmailSyncJob.cs
@@ -1,5 +1,6 @@
using MimeKit;
using Quartz;
+using Service.EmailServices;
namespace Service.Jobs;
diff --git a/Service/MessageRecordService.cs b/Service/MessageRecordService.cs
index b637276..e44b637 100644
--- a/Service/MessageRecordService.cs
+++ b/Service/MessageRecordService.cs
@@ -44,7 +44,7 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
{
var message = await messageRepo.GetByIdAsync(id);
if (message == null) return false;
-
+
message.IsRead = true;
message.UpdateTime = DateTime.Now;
return await messageRepo.UpdateAsync(message);
diff --git a/Service/OpenAiService.cs b/Service/OpenAiService.cs
index 755b2ea..2372b5a 100644
--- a/Service/OpenAiService.cs
+++ b/Service/OpenAiService.cs
@@ -44,7 +44,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
-
+
try
{
using var resp = await http.PostAsync(url, content);
@@ -100,7 +100,7 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
-
+
try
{
using var resp = await http.PostAsync(url, content);
@@ -157,13 +157,13 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
-
+
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
-
+
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
@@ -230,14 +230,14 @@ public class OpenAiService(
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
-
+
// 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = content
};
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
-
+
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
diff --git a/Service/PeriodicBillBackgroundService.cs b/Service/PeriodicBillBackgroundService.cs
index 45299e1..8b9311a 100644
--- a/Service/PeriodicBillBackgroundService.cs
+++ b/Service/PeriodicBillBackgroundService.cs
@@ -19,7 +19,7 @@ public class PeriodicBillBackgroundService(
try
{
var now = DateTime.Now;
-
+
// 计算下次执行时间(每天早上6点)
var nextRun = now.Date.AddHours(6);
if (now >= nextRun)
diff --git a/Service/SmartHandleService.cs b/Service/SmartHandleService.cs
index 6de2a03..286d154 100644
--- a/Service/SmartHandleService.cs
+++ b/Service/SmartHandleService.cs
@@ -112,33 +112,33 @@ public class SmartHandleService(
}
var systemPrompt = $$"""
- 你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
+ 你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
- 可用的分类列表:
- {{categoryInfo}}
+ 可用的分类列表:
+ {{categoryInfo}}
- 分类规则:
- 1. 根据账单的摘要和涉及金额,选择最匹配的分类
- 2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
- 3. 如果无法确定分类,可以选择"其他"
- 4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
+ 分类规则:
+ 1. 根据账单的摘要和涉及金额,选择最匹配的分类
+ 2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
+ 3. 如果无法确定分类,可以选择"其他"
+ 4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
- 输出格式要求(强制):
- - 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
- - 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
- - 不要输出任何解释性文字、编号、标点或多余的文本
- - 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
+ 输出格式要求(强制):
+ - 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
+ - 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
+ - 不要输出任何解释性文字、编号、标点或多余的文本
+ - 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
- 只输出按行的JSON对象(NDJSON),不要有其他文字说明。
- """;
+ 只输出按行的JSON对象(NDJSON),不要有其他文字说明。
+ """;
var userPrompt = $$"""
- 请为以下账单分组进行分类:
+ 请为以下账单分组进行分类:
- {{billsInfo}}
+ {{billsInfo}}
- 请逐个输出分类结果。
- """;
+ 请逐个输出分类结果。
+ """;
// 流式调用AI
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
@@ -253,56 +253,56 @@ public class SmartHandleService(
// 第一步:使用AI生成聚合SQL查询
var now = DateTime.Now;
var sqlPrompt = $"""
- 当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
- 用户问题:{userInput}
-
- 数据库类型:SQLite
- 数据库表名:TransactionRecord
- 字段说明:
- - Id: bigint 主键
- - Card: nvarchar 卡号
- - Reason: nvarchar 交易原因/摘要
- - Amount: decimal 交易金额(支出为负数,收入为正数)
- - OccurredAt: datetime 交易发生时间(TEXT类型,格式:'2025-12-26 10:30:00')
- - Type: int 交易类型(0=支出, 1=收入, 2=不计入收支)
- - Classify: nvarchar 交易分类(如:交通、餐饮、购物等)
-
- 【核心原则】直接生成用户所需的聚合统计SQL,而不是查询原始记录后再统计
-
- 要求:
- 1. 根据用户问题判断需要什么维度的聚合数据
- 2. 使用 GROUP BY 按分类、时间等维度分组
- 3. 使用聚合函数:SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小
- 4. 时间范围使用 OccurredAt 字段,"最近X个月/天"基于当前日期计算
- 5. 支出用 Type = 0,收入用 Type = 1
- 6. 给聚合字段起有意义的别名(如 TotalAmount, TransactionCount, AvgAmount)
- 7. 使用 ORDER BY 对结果排序(通常按金额降序)
- 8. 只返回SQL语句,不要解释
-
- 【重要】SQLite日期函数:
- - 提取年份:strftime('%Y', OccurredAt)
- - 提取月份:strftime('%m', OccurredAt)
- - 提取日期:strftime('%Y-%m-%d', OccurredAt)
- - 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
-
- 示例1(按分类统计):
- 用户:这三个月坐车花了多少钱?
- 返回:SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%交通%' OR Reason LIKE '%打车%' OR Reason LIKE '%公交%' OR Reason LIKE '%地铁%') GROUP BY Classify ORDER BY TotalAmount DESC
-
- 示例2(按月统计):
- 用户:最近半年每月支出情况
- 返回:SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month
-
- 示例3(总体统计):
- 用户:本月花了多少钱?
- 返回:SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01'
-
- 示例4(详细记录 - 仅在用户明确要求详情时使用):
- 用户:单笔超过1000元的支出有哪些?
- 返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
-
- 只返回SQL语句。
- """;
+ 当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
+ 用户问题:{userInput}
+
+ 数据库类型:SQLite
+ 数据库表名:TransactionRecord
+ 字段说明:
+ - Id: bigint 主键
+ - Card: nvarchar 卡号
+ - Reason: nvarchar 交易原因/摘要
+ - Amount: decimal 交易金额(支出为负数,收入为正数)
+ - OccurredAt: datetime 交易发生时间(TEXT类型,格式:'2025-12-26 10:30:00')
+ - Type: int 交易类型(0=支出, 1=收入, 2=不计入收支)
+ - Classify: nvarchar 交易分类(如:交通、餐饮、购物等)
+
+ 【核心原则】直接生成用户所需的聚合统计SQL,而不是查询原始记录后再统计
+
+ 要求:
+ 1. 根据用户问题判断需要什么维度的聚合数据
+ 2. 使用 GROUP BY 按分类、时间等维度分组
+ 3. 使用聚合函数:SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小
+ 4. 时间范围使用 OccurredAt 字段,"最近X个月/天"基于当前日期计算
+ 5. 支出用 Type = 0,收入用 Type = 1
+ 6. 给聚合字段起有意义的别名(如 TotalAmount, TransactionCount, AvgAmount)
+ 7. 使用 ORDER BY 对结果排序(通常按金额降序)
+ 8. 只返回SQL语句,不要解释
+
+ 【重要】SQLite日期函数:
+ - 提取年份:strftime('%Y', OccurredAt)
+ - 提取月份:strftime('%m', OccurredAt)
+ - 提取日期:strftime('%Y-%m-%d', OccurredAt)
+ - 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
+
+ 示例1(按分类统计):
+ 用户:这三个月坐车花了多少钱?
+ 返回:SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%交通%' OR Reason LIKE '%打车%' OR Reason LIKE '%公交%' OR Reason LIKE '%地铁%') GROUP BY Classify ORDER BY TotalAmount DESC
+
+ 示例2(按月统计):
+ 用户:最近半年每月支出情况
+ 返回:SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month
+
+ 示例3(总体统计):
+ 用户:本月花了多少钱?
+ 返回:SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01'
+
+ 示例4(详细记录 - 仅在用户明确要求详情时使用):
+ 用户:单笔超过1000元的支出有哪些?
+ 返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
+
+ 只返回SQL语句。
+ """;
var sqlText = await openAiService.ChatAsync(sqlPrompt);
@@ -339,39 +339,39 @@ public class SmartHandleService(
});
var dataPrompt = $"""
- 当前日期:{DateTime.Now:yyyy年M月d日}
- 用户问题:{userInput}
-
- 查询结果数据(JSON格式):
- {dataJson}
-
- 说明:以上数据是根据用户问题查询出的聚合统计结果,请基于这些数据生成分析报告。
-
- 请生成一份专业的数据分析报告,严格遵守以下要求:
-
- 【格式要求】
- 1. 使用HTML格式(移动端H5页面风格)
- 2. 生成清晰的报告标题(基于用户问题)
- 3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
- 4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
- 5. 支出金额用 金额 包裹
- 6. 收入金额用 金额 包裹
- 7. 重要结论用 内容 高亮
-
- 【样式限制(重要)】
- 8. 不要包含 html、body、head 标签
- 9. 不要使用任何 style 属性或