diff --git a/Service/EmailHandleService.cs b/Service/EmailHandleService.cs index 145efe8..b0b1fe5 100644 --- a/Service/EmailHandleService.cs +++ b/Service/EmailHandleService.cs @@ -21,7 +21,8 @@ public class EmailHandleService( IEmailMessageRepository emailRepo, ITransactionRecordRepository trxRepo, IEnumerable emailParsers, - IMessageRecordService messageRecordService + IMessageRecordService messageRecordService, + ISmartHandleService smartHandleService ) : IEmailHandleService { public async Task HandleEmailAsync( @@ -79,11 +80,12 @@ public class EmailHandleService( // 目前已经 bool allSuccess = true; + var records = new List(); foreach (var (card, reason, amount, balance, type, occurredAt) in parsed) { logger.LogInformation("处理交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type); - var success = await SaveTransactionRecordAsync( + var record = await SaveTransactionRecordAsync( card, reason, amount, @@ -93,12 +95,17 @@ public class EmailHandleService( emailMessage.Id ); - if (!success) + if (record == null) { allSuccess = false; + continue; } + + records.Add(record); } + _ = await AnalyzeClassifyAsync(records.ToArray()); + return allSuccess; } @@ -141,11 +148,12 @@ public class EmailHandleService( logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length); bool allSuccess = true; + var records = new List(); foreach (var (card, reason, amount, balance, type, occurredAt) in parsed) { logger.LogInformation("刷新交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type); - var success = await SaveTransactionRecordAsync( + var record = await SaveTransactionRecordAsync( card, reason, amount, @@ -155,12 +163,17 @@ public class EmailHandleService( emailMessage.Id ); - if (!success) + if (record == null) { allSuccess = false; + continue; } + + records.Add(record); } + _ = await AnalyzeClassifyAsync(records.ToArray()); + return allSuccess; } @@ -221,7 +234,7 @@ public class EmailHandleService( } } - private async Task SaveTransactionRecordAsync( + private async Task SaveTransactionRecordAsync( string card, string reason, decimal amount, @@ -251,9 +264,10 @@ public class EmailHandleService( else { logger.LogWarning("交易记录更新失败,卡号 {Card}, 金额 {Amount}", card, amount); + return null; } - return updated; + return existing; } var trx = new TransactionRecord @@ -276,9 +290,10 @@ public class EmailHandleService( else { logger.LogWarning("交易记录落库失败,卡号 {Card}, 金额 {Amount}", card, amount); + return null; } - return inserted; + return trx; } private async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailBodyAsync(string from, string subject, string body) @@ -293,4 +308,59 @@ public class EmailHandleService( return await service.ParseAsync(body); } + + private async Task AnalyzeClassifyAsync(TransactionRecord[] records) + { + var result = new List(); + await smartHandleService.SmartClassifyAsync(records.Select(r => r.Id).ToArray(), chunk => + { + // 处理分类结果 + var (type, data) = chunk; + + if (type != "data") + { + logger.LogWarning("未知的分类结果类型: {Type}, {Data}. 跳过分类", type, data); + return; + } + + try + { + var item = JsonSerializer.Deserialize(data); + + var recordId = item?["id"]?.GetValue(); + var classify = item?["Classify"]?.GetValue(); + var recordType = item?["Type"]?.GetValue(); + + if (recordId == null || string.IsNullOrEmpty(classify) || recordType == null) + { + logger.LogWarning("AI分类结果数据不完整,跳过分类: {Data}", data); + return; + } + + if (recordType < (int)TransactionType.Expense || recordType > (int)TransactionType.None) + { + logger.LogWarning("AI分类结果交易类型无效,跳过分类: {Data}", data); + return; + } + + var record = records.FirstOrDefault(r => r.Id == recordId); + if (record == null) + { + logger.LogWarning("未找到对应的交易记录(AI返回内容有误),跳过分类,ID: {Id}", recordId); + return; + } + + record.Classify = classify; + record.Type = (TransactionType)recordType; + + result.Add(record); + } + catch (Exception ex) + { + logger.LogWarning(ex, "解析AI分类结果失败,跳过分类: {Data}", data); + } + }); + + return result.ToArray(); + } } \ No newline at end of file diff --git a/Service/EmailParseServices/EmailParseForm95555.cs b/Service/EmailParseServices/EmailParseForm95555.cs index d5f9f16..a9e8e7e 100644 --- a/Service/EmailParseServices/EmailParseForm95555.cs +++ b/Service/EmailParseServices/EmailParseForm95555.cs @@ -35,7 +35,13 @@ public class EmailParseForm95555( DateTime? occurredAt )[]> ParseEmailContentAsync(string emailContent) { - var pattern = "您账户(?\\d+)于.*?(?收入|支出|消费|转入|转出)?.*?在?(?.+?)(?\\d+\\.\\d{1,2})元,余额(?\\d+\\.\\d{1,2})"; + var pattern = + "您账户(?\\d+)" + + "于.*?" + // 时间等信息统统吞掉 + "(?:(?收入|支出|消费|转入|转出).*?)?" + // 可选 type + "(?:在(?.*?))?" + // 可选 reason(“财付通-微信支付-这有电快捷支付”) + "(?\\d+\\.\\d{1,2})元,余额" + + "(?\\d+\\.\\d{1,2})"; var matches = Regex.Matches(emailContent, pattern); diff --git a/Service/EmailParseServices/EmailParseFormCCSVC.cs b/Service/EmailParseServices/EmailParseFormCCSVC.cs index 063c5b5..0f04625 100644 --- a/Service/EmailParseServices/EmailParseFormCCSVC.cs +++ b/Service/EmailParseServices/EmailParseFormCCSVC.cs @@ -144,6 +144,17 @@ public class EmailParseFormCCSVC( reason = string.Join(" ", parts.Skip(2)); } + // 招商信用卡特殊,消费金额为正数,退款为负数 + if(amount > 0) + { + type = TransactionType.Expense; + } + else + { + type = TransactionType.Income; + amount = Math.Abs(amount); + } + result.Add((card, reason, amount, balance, type, occurredAt)); } catch (Exception ex) diff --git a/Service/GlobalUsings.cs b/Service/GlobalUsings.cs index e78ee92..38b7571 100644 --- a/Service/GlobalUsings.cs +++ b/Service/GlobalUsings.cs @@ -10,4 +10,5 @@ global using Entity; global using FreeSql; global using System.Linq; global using Service.AppSettingModel; -global using System.Text.Json.Serialization; \ No newline at end of file +global using System.Text.Json.Serialization; +global using System.Text.Json.Nodes; \ No newline at end of file diff --git a/Service/SmartClassify.cs b/Service/SmartHandleService.cs similarity index 55% rename from Service/SmartClassify.cs rename to Service/SmartHandleService.cs index c7da11c..0fd50d3 100644 --- a/Service/SmartClassify.cs +++ b/Service/SmartHandleService.cs @@ -2,7 +2,9 @@ public interface ISmartHandleService { - Task SmartClassifyAsync(long[] transactionIds, Action<(string , string)> chunkAction); + Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction); + + Task AnalyzeBillAsync(string userInput, Action chunkAction); } public class SmartHandleService( @@ -13,7 +15,7 @@ public class SmartHandleService( IOpenAiService openAiService ) : ISmartHandleService { - public async Task SmartClassifyAsync(long[] transactionIds, Action<(string , string)> chunkAction) + public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction) { try { @@ -199,6 +201,151 @@ public class SmartHandleService( } } + public async Task AnalyzeBillAsync(string userInput, Action chunkAction) + { + try + { + // 第一步:使用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语句。 + """; + + var sqlText = await openAiService.ChatAsync(sqlPrompt); + + // 清理SQL文本 + sqlText = sqlText?.Trim() ?? ""; + sqlText = sqlText.TrimStart('`').TrimEnd('`'); + if (sqlText.StartsWith("sql", StringComparison.OrdinalIgnoreCase)) + { + sqlText = sqlText.Substring(3).Trim(); + } + + logger.LogInformation("AI生成的SQL: {Sql}", sqlText); + + // 第二步:执行动态SQL查询 + List queryResults; + try + { + queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText); + } + catch (Exception ex) + { + logger.LogError(ex, "执行AI生成的SQL失败: {Sql}", sqlText); + // 如果SQL执行失败,返回错误 + var errorData = JsonSerializer.Serialize(new { content = "
SQL执行失败,请重新描述您的问题
" }); + chunkAction(errorData); + return; + } + + // 第三步:将查询结果序列化为JSON,直接传递给AI生成分析报告 + var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + 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 属性或