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 属性或