fix
This commit is contained in:
@@ -9,6 +9,29 @@ public interface ISmartHandleService
|
||||
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
|
||||
|
||||
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
|
||||
|
||||
/// <summary>
|
||||
/// 从邮件正文中使用AI提取交易记录(AI兜底方案)
|
||||
/// </summary>
|
||||
Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody);
|
||||
|
||||
/// <summary>
|
||||
/// 为分类生成多个SVG图标(定时任务使用)
|
||||
/// </summary>
|
||||
Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5);
|
||||
|
||||
/// <summary>
|
||||
/// 为分类生成单个SVG图标(手动触发使用)
|
||||
/// </summary>
|
||||
Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType);
|
||||
|
||||
/// <summary>
|
||||
/// 生成预算执行报告(HTML格式)
|
||||
/// </summary>
|
||||
/// <param name="promptWithData">完整的Prompt(包含数据和格式要求)</param>
|
||||
/// <param name="year">年份</param>
|
||||
/// <param name="month">月份</param>
|
||||
Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month);
|
||||
}
|
||||
|
||||
public class SmartHandleService(
|
||||
@@ -538,6 +561,365 @@ public class SmartHandleService(
|
||||
|
||||
return categoryInfo.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从邮件正文中使用AI提取交易记录(AI兜底方案)
|
||||
/// </summary>
|
||||
public async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody)
|
||||
{
|
||||
var systemPrompt = $"""
|
||||
你是一个信息抽取助手。
|
||||
仅输出严格的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{emailBody}
|
||||
""";
|
||||
|
||||
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(contentText))
|
||||
{
|
||||
logger.LogWarning("AI未返回任何内容,无法解析邮件");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogDebug("AI返回的内容: {Content}", contentText);
|
||||
// 清理可能的 markdown 代码块标记
|
||||
contentText = contentText.Trim();
|
||||
if (contentText.StartsWith("```"))
|
||||
{
|
||||
// 移除开头的 ```json 或 ```
|
||||
var firstNewLine = contentText.IndexOf('\n');
|
||||
if (firstNewLine > 0)
|
||||
{
|
||||
contentText = contentText.Substring(firstNewLine + 1);
|
||||
}
|
||||
|
||||
// 移除结尾的 ```
|
||||
if (contentText.EndsWith("```"))
|
||||
{
|
||||
contentText = contentText.Substring(0, contentText.Length - 3);
|
||||
}
|
||||
|
||||
contentText = contentText.Trim();
|
||||
}
|
||||
|
||||
// contentText 期望是 JSON 数组
|
||||
using var jsonDoc = JsonDocument.Parse(contentText);
|
||||
var arrayElement = jsonDoc.RootElement;
|
||||
|
||||
// 如果返回的是单个对象而不是数组,尝试兼容处理
|
||||
if (arrayElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
logger.LogWarning("AI返回的内容是单个对象而非数组,尝试兼容处理");
|
||||
var result = ParseEmailSingleRecord(arrayElement);
|
||||
return result != null ? [result.Value] : null;
|
||||
}
|
||||
|
||||
if (arrayElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
logger.LogWarning("AI返回的内容不是JSON数组,无法解析邮件");
|
||||
return null;
|
||||
}
|
||||
|
||||
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
|
||||
|
||||
foreach (var obj in arrayElement.EnumerateArray())
|
||||
{
|
||||
var record = ParseEmailSingleRecord(obj);
|
||||
if (record != null)
|
||||
{
|
||||
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
|
||||
results.Add(record.Value);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("使用AI成功解析邮件内容,提取到 {Count} 条交易记录", results.Count);
|
||||
return results.Count > 0 ? results.ToArray() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为分类生成多个SVG图标(定时任务使用)
|
||||
/// </summary>
|
||||
public async Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5)
|
||||
{
|
||||
logger.LogInformation("正在为分类 {CategoryName} 生成 {IconCount} 个图标", categoryName, iconCount);
|
||||
|
||||
var typeText = categoryType == TransactionType.Expense ? "支出" : "收入";
|
||||
|
||||
var systemPrompt = """
|
||||
你是一个专业的 SVG 图标设计师,擅长创作精美、富有表现力的图标。
|
||||
请根据分类名称和类型,生成 5 个风格迥异、视觉效果突出的 SVG 图标。
|
||||
|
||||
设计要求:
|
||||
1. 尺寸:24x24,viewBox="0 0 24 24"
|
||||
2. 色彩:使用丰富的渐变色和多色搭配,让图标更有吸引力和辨识度
|
||||
- 可以使用 <linearGradient> 或 <radialGradient> 创建渐变效果
|
||||
- 不同元素使用不同颜色,增加层次感
|
||||
- 根据分类含义选择合适的配色方案(如餐饮用暖色系、交通用蓝色系等)
|
||||
3. 设计风格:5 个图标必须风格明显不同,避免雷同
|
||||
- 第1个:扁平化风格,色彩鲜明,使用渐变
|
||||
- 第2个:线性风格,多色描边,细节丰富
|
||||
- 第3个:3D立体风格,使用阴影和高光效果
|
||||
- 第4个:卡通可爱风格,圆润造型,活泼配色
|
||||
- 第5个:现代简约风格,几何与曲线结合,优雅配色
|
||||
4. 细节丰富:不要只用简单的几何图形,添加特征性的细节元素
|
||||
- 例如:餐饮可以加刀叉、蒸汽、食材纹理等
|
||||
- 交通可以加轮胎、车窗、尾气等
|
||||
- 每个图标要有独特的视觉记忆点
|
||||
5. 图标要直观表达分类含义,让人一眼就能识别
|
||||
6. 只返回 JSON 数组格式,包含 5 个完整的 SVG 字符串,不要有任何其他文字说明
|
||||
|
||||
重要:每个 SVG 必须是自包含的完整代码,包含所有必要的 gradient 定义。
|
||||
""";
|
||||
|
||||
var userPrompt = $"""
|
||||
分类名称:{categoryName}
|
||||
分类类型:{typeText}
|
||||
|
||||
请为这个分类生成 {iconCount} 个精美的、风格各异的彩色 SVG 图标。
|
||||
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
|
||||
|
||||
返回格式(纯 JSON 数组,无其他内容):
|
||||
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
|
||||
""";
|
||||
|
||||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
{
|
||||
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证返回的是有效的 JSON 数组
|
||||
try
|
||||
{
|
||||
var icons = JsonSerializer.Deserialize<List<string>>(response);
|
||||
if (icons == null || icons.Count != iconCount)
|
||||
{
|
||||
logger.LogWarning("AI 返回的图标数量不正确(期望{IconCount}个),分类: {CategoryName}", iconCount, categoryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation("成功为分类 {CategoryName} 生成了 {IconCount} 个图标", categoryName, iconCount);
|
||||
return icons;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
|
||||
categoryName, response);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为分类生成单个SVG图标(手动触发使用)
|
||||
/// </summary>
|
||||
public async Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType)
|
||||
{
|
||||
logger.LogInformation("正在为分类 {CategoryName} 生成单个图标", categoryName);
|
||||
|
||||
var typeText = categoryType == TransactionType.Expense ? "支出" : "收入";
|
||||
|
||||
var systemPrompt = """
|
||||
你是一个专业的SVG图标设计师。为预算分类生成极简风格的SVG图标。
|
||||
|
||||
设计要求:
|
||||
1. 尺寸:24x24,viewBox="0 0 24 24"
|
||||
2. 使用丰富的渐变色和多色搭配,让图标更有吸引力
|
||||
3. 图标要直观表达分类含义
|
||||
4. 只返回SVG代码,不要有任何其他文字说明
|
||||
""";
|
||||
|
||||
var userPrompt = $"""
|
||||
请为「{categoryName}」{typeText}分类生成一个精美的SVG图标。
|
||||
直接返回SVG代码,无需解释。
|
||||
""";
|
||||
|
||||
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
|
||||
if (string.IsNullOrWhiteSpace(svgContent))
|
||||
{
|
||||
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取SVG标签
|
||||
var svgMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
svgContent,
|
||||
@"<svg[^>]*>.*?</svg>",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
if (!svgMatch.Success)
|
||||
{
|
||||
logger.LogWarning("生成的内容不包含有效的SVG标签,分类: {CategoryName}", categoryName);
|
||||
return null;
|
||||
}
|
||||
|
||||
var svg = svgMatch.Value;
|
||||
logger.LogInformation("成功为分类 {CategoryName} 生成单个图标", categoryName);
|
||||
return svg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成预算执行报告(HTML格式)
|
||||
/// </summary>
|
||||
public async Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month)
|
||||
{
|
||||
logger.LogInformation("正在生成预算执行报告: {Year}年{Month}月", year, month);
|
||||
|
||||
// 直接使用传入的完整prompt(包含数据和格式要求)
|
||||
var htmlReport = await openAiService.ChatAsync(promptWithData);
|
||||
if (string.IsNullOrWhiteSpace(htmlReport))
|
||||
{
|
||||
logger.LogWarning("AI 未返回有效的报告内容");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation("成功生成预算执行报告: {Year}年{Month}月", year, month);
|
||||
return htmlReport;
|
||||
}
|
||||
|
||||
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseEmailSingleRecord(JsonElement obj)
|
||||
{
|
||||
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
|
||||
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
|
||||
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
|
||||
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var amount = 0m;
|
||||
if (obj.TryGetProperty("amount", out var pAmount))
|
||||
{
|
||||
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
|
||||
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
|
||||
}
|
||||
|
||||
var balance = 0m;
|
||||
if (obj.TryGetProperty("balance", out var pBalance))
|
||||
{
|
||||
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
|
||||
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var occurredAt = (DateTime?)null;
|
||||
if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
|
||||
{
|
||||
occurredAt = occurredAtValue;
|
||||
}
|
||||
|
||||
var type = DetermineTransactionType(typeStr, reason, amount);
|
||||
return (card, reason, amount, balance, type, occurredAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断交易类型
|
||||
/// </summary>
|
||||
private TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
|
||||
{
|
||||
// 优先使用明确的类型字符串
|
||||
if (!string.IsNullOrWhiteSpace(typeStr))
|
||||
{
|
||||
if (typeStr.Contains("收入") || typeStr.Contains("income") || typeStr.Equals("收", StringComparison.OrdinalIgnoreCase))
|
||||
return TransactionType.Income;
|
||||
if (typeStr.Contains("支出") || typeStr.Contains("expense") || typeStr.Equals("支", StringComparison.OrdinalIgnoreCase))
|
||||
return TransactionType.Expense;
|
||||
}
|
||||
|
||||
// 根据交易原因中的关键词判断
|
||||
var lowerReason = reason.ToLower();
|
||||
|
||||
// 收入关键词
|
||||
string[] incomeKeywords =
|
||||
[
|
||||
"工资", "奖金", "退款",
|
||||
"返现", "收入", "转入",
|
||||
"存入", "利息", "分红",
|
||||
"入账", "收款",
|
||||
|
||||
// 常见扩展
|
||||
"实发工资", "薪资", "薪水", "薪酬",
|
||||
"提成", "佣金", "劳务费",
|
||||
"报销入账", "报销款", "补贴", "补助",
|
||||
|
||||
"退款成功", "退回", "退货退款",
|
||||
"返现入账", "返利", "返佣",
|
||||
|
||||
"到账", "已到账", "入账成功",
|
||||
"收款成功", "收到款项", "到账金额",
|
||||
"资金转入", "资金收入",
|
||||
|
||||
"转账收入", "转账入账", "他行来账",
|
||||
"工资代发", "代发工资", "单位打款",
|
||||
|
||||
"利息收入", "收益", "收益发放", "理财收益",
|
||||
"分红收入", "股息", "红利",
|
||||
|
||||
// 平台常用词
|
||||
"红包", "红包收入", "红包入账",
|
||||
"奖励金", "活动奖励", "补贴金",
|
||||
"现金奖励", "推广奖励", "返现奖励",
|
||||
|
||||
// 存取类
|
||||
"现金存入", "柜台存入", "ATM存入",
|
||||
"他人转入", "他人汇入"
|
||||
];
|
||||
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
||||
return TransactionType.Income;
|
||||
|
||||
// 支出关键词
|
||||
string[] expenseKeywords =
|
||||
[
|
||||
"消费", "支付", "购买",
|
||||
"转出", "取款", "支出",
|
||||
"扣款", "缴费", "付款",
|
||||
"刷卡",
|
||||
|
||||
// 常见扩展
|
||||
"支出金额", "支出人民币", "已支出",
|
||||
"已消费", "消费支出", "消费人民币",
|
||||
"已支付", "成功支付", "支付成功", "交易支付",
|
||||
"已扣款", "扣款成功", "扣费", "扣费成功",
|
||||
"转账", "转账支出", "向外转账", "已转出",
|
||||
"提现", "现金支出", "现金取款",
|
||||
"扣除", "扣除金额", "记账支出",
|
||||
|
||||
// 账单/通知类用语
|
||||
"本期应还", "本期应还金额", "本期账单金额",
|
||||
"本期应还人民币", "最低还款额",
|
||||
"本期欠款", "欠款金额",
|
||||
|
||||
// 线上平台常见用语
|
||||
"订单支付", "订单扣款", "订单消费",
|
||||
"交易支出", "交易扣款", "交易成功支出",
|
||||
"话费充值", "流量充值", "水费", "电费", "燃气费",
|
||||
"物业费", "服务费", "手续费", "年费", "会费",
|
||||
"利息支出", "还款支出", "代扣", "代缴",
|
||||
|
||||
// 信用卡/花呗等场景
|
||||
"信用卡还款", "花呗还款", "白条还款",
|
||||
"分期还款", "账单还款", "自动还款"
|
||||
];
|
||||
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
||||
return TransactionType.Expense;
|
||||
|
||||
// 根据金额正负判断(如果金额为负数,可能是支出)
|
||||
if (amount < 0)
|
||||
return TransactionType.Expense;
|
||||
if (amount > 0)
|
||||
return TransactionType.Income;
|
||||
|
||||
// 默认为支出
|
||||
return TransactionType.Expense;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,7 +34,7 @@ public class BudgetService(
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
ITransactionStatisticsService transactionStatisticsService,
|
||||
IOpenAiService openAiService,
|
||||
ISmartHandleService smartHandleService,
|
||||
IMessageService messageService,
|
||||
ILogger<BudgetService> logger,
|
||||
IBudgetSavingsService budgetSavingsService,
|
||||
@@ -343,7 +343,8 @@ public class BudgetService(
|
||||
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
||||
""";
|
||||
|
||||
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
||||
// 使用 SmartHandleService 统一封装的报告生成方法
|
||||
var htmlReport = await smartHandleService.GenerateBudgetReportAsync(dataPrompt, year, month);
|
||||
if (!string.IsNullOrEmpty(htmlReport))
|
||||
{
|
||||
await messageService.AddAsync(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Service.AI;
|
||||
using Service.AI;
|
||||
|
||||
namespace Service.EmailServices.EmailParse;
|
||||
|
||||
public class EmailParseForm95555(
|
||||
ILogger<EmailParseForm95555> logger,
|
||||
IOpenAiService openAiService
|
||||
) : EmailParseServicesBase(logger, openAiService)
|
||||
ISmartHandleService smartHandleService
|
||||
) : EmailParseServicesBase(logger, smartHandleService)
|
||||
{
|
||||
public override bool CanParse(string from, string subject, string body)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using HtmlAgilityPack;
|
||||
using HtmlAgilityPack;
|
||||
using Service.AI;
|
||||
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
|
||||
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
||||
@@ -8,8 +8,8 @@ namespace Service.EmailServices.EmailParse;
|
||||
[UsedImplicitly]
|
||||
public partial class EmailParseFormCcsvc(
|
||||
ILogger<EmailParseFormCcsvc> logger,
|
||||
IOpenAiService openAiService
|
||||
) : EmailParseServicesBase(logger, openAiService)
|
||||
ISmartHandleService smartHandleService
|
||||
) : EmailParseServicesBase(logger, smartHandleService)
|
||||
{
|
||||
[GeneratedRegex("<.*?>")]
|
||||
private static partial Regex HtmlRegex();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Service.AI;
|
||||
using Service.AI;
|
||||
|
||||
namespace Service.EmailServices.EmailParse;
|
||||
|
||||
@@ -21,7 +21,7 @@ public interface IEmailParseServices
|
||||
|
||||
public abstract class EmailParseServicesBase(
|
||||
ILogger<EmailParseServicesBase> logger,
|
||||
IOpenAiService openAiService
|
||||
ISmartHandleService smartHandleService
|
||||
) : IEmailParseServices
|
||||
{
|
||||
public abstract bool CanParse(string from, string subject, string body);
|
||||
@@ -44,8 +44,8 @@ public abstract class EmailParseServicesBase(
|
||||
}
|
||||
|
||||
logger.LogInformation("规则解析邮件内容失败,尝试使用AI进行解析");
|
||||
// AI兜底
|
||||
result = await ParseByAiAsync(emailContent) ?? [];
|
||||
// AI兜底 - 使用 SmartHandleService 统一封装
|
||||
result = await smartHandleService.ParseEmailByAiAsync(emailContent) ?? [];
|
||||
|
||||
if (result.Length == 0)
|
||||
{
|
||||
@@ -64,128 +64,8 @@ public abstract class EmailParseServicesBase(
|
||||
DateTime? occurredAt
|
||||
)[]> ParseEmailContentAsync(string emailContent);
|
||||
|
||||
private async Task<(
|
||||
string card,
|
||||
string reason,
|
||||
decimal amount,
|
||||
decimal balance,
|
||||
TransactionType type,
|
||||
DateTime? occurredAt
|
||||
)[]?> 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},请根据当前时间推断交易发生的时间。
|
||||
""";
|
||||
var userPrompt = $"""
|
||||
从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,
|
||||
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
|
||||
正文如下:\n\n{body}
|
||||
""";
|
||||
|
||||
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||
if (string.IsNullOrWhiteSpace(contentText))
|
||||
{
|
||||
logger.LogWarning("AI未返回任何内容,无法解析邮件");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogDebug("AI返回的内容: {Content}", contentText);
|
||||
// 清理可能的 markdown 代码块标记
|
||||
contentText = contentText.Trim();
|
||||
if (contentText.StartsWith("```"))
|
||||
{
|
||||
// 移除开头的 ```json 或 ```
|
||||
var firstNewLine = contentText.IndexOf('\n');
|
||||
if (firstNewLine > 0)
|
||||
{
|
||||
contentText = contentText.Substring(firstNewLine + 1);
|
||||
}
|
||||
|
||||
// 移除结尾的 ```
|
||||
if (contentText.EndsWith("```"))
|
||||
{
|
||||
contentText = contentText.Substring(0, contentText.Length - 3);
|
||||
}
|
||||
|
||||
contentText = contentText.Trim();
|
||||
}
|
||||
|
||||
// contentText 期望是 JSON 数组
|
||||
using var jsonDoc = JsonDocument.Parse(contentText);
|
||||
var arrayElement = jsonDoc.RootElement;
|
||||
|
||||
// 如果返回的是单个对象而不是数组,尝试兼容处理
|
||||
if (arrayElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
logger.LogWarning("AI返回的内容是单个对象而非数组,尝试兼容处理");
|
||||
var result = ParseSingleRecord(arrayElement);
|
||||
return result != null ? [result.Value] : null;
|
||||
}
|
||||
|
||||
if (arrayElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
logger.LogWarning("AI返回的内容不是JSON数组,无法解析邮件");
|
||||
return null;
|
||||
}
|
||||
|
||||
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
|
||||
|
||||
foreach (var obj in arrayElement.EnumerateArray())
|
||||
{
|
||||
var record = ParseSingleRecord(obj);
|
||||
if (record != null)
|
||||
{
|
||||
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
|
||||
results.Add(record.Value);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("使用AI成功解析邮件内容,提取到 {Count} 条交易记录", results.Count);
|
||||
return results.Count > 0 ? results.ToArray() : null;
|
||||
}
|
||||
|
||||
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseSingleRecord(JsonElement obj)
|
||||
{
|
||||
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
|
||||
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
|
||||
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
|
||||
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var amount = 0m;
|
||||
if (obj.TryGetProperty("amount", out var pAmount))
|
||||
{
|
||||
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
|
||||
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
|
||||
}
|
||||
|
||||
var balance = 0m;
|
||||
if (obj.TryGetProperty("balance", out var pBalance))
|
||||
{
|
||||
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
|
||||
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var occurredAt = (DateTime?)null;
|
||||
if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
|
||||
{
|
||||
occurredAt = occurredAtValue;
|
||||
}
|
||||
|
||||
var type = DetermineTransactionType(typeStr, reason, amount);
|
||||
return (card, reason, amount, balance, type, occurredAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断交易类型
|
||||
/// 判断交易类型(供子类使用的通用方法)
|
||||
/// </summary>
|
||||
protected TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Quartz;
|
||||
using Quartz;
|
||||
using Service.AI;
|
||||
|
||||
namespace Service.Jobs;
|
||||
@@ -9,7 +9,7 @@ namespace Service.Jobs;
|
||||
/// </summary>
|
||||
public class CategoryIconGenerationJob(
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
IOpenAiService openAiService,
|
||||
ISmartHandleService smartHandleService,
|
||||
ILogger<CategoryIconGenerationJob> logger) : IJob
|
||||
{
|
||||
private static readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
@@ -77,74 +77,21 @@ public class CategoryIconGenerationJob(
|
||||
logger.LogInformation("正在为分类 {CategoryName}(ID:{CategoryId}) 生成图标",
|
||||
category.Name, category.Id);
|
||||
|
||||
var typeText = category.Type == TransactionType.Expense ? "支出" : "收入";
|
||||
// 使用 SmartHandleService 统一封装的图标生成方法
|
||||
var icons = await smartHandleService.GenerateCategoryIconsAsync(category.Name, category.Type, iconCount: 5);
|
||||
|
||||
var systemPrompt = """
|
||||
你是一个专业的 SVG 图标设计师,擅长创作精美、富有表现力的图标。
|
||||
请根据分类名称和类型,生成 5 个风格迥异、视觉效果突出的 SVG 图标。
|
||||
|
||||
设计要求:
|
||||
1. 尺寸:24x24,viewBox="0 0 24 24"
|
||||
2. 色彩:使用丰富的渐变色和多色搭配,让图标更有吸引力和辨识度
|
||||
- 可以使用 <linearGradient> 或 <radialGradient> 创建渐变效果
|
||||
- 不同元素使用不同颜色,增加层次感
|
||||
- 根据分类含义选择合适的配色方案(如餐饮用暖色系、交通用蓝色系等)
|
||||
3. 设计风格:5 个图标必须风格明显不同,避免雷同
|
||||
- 第1个:扁平化风格,色彩鲜明,使用渐变
|
||||
- 第2个:线性风格,多色描边,细节丰富
|
||||
- 第3个:3D立体风格,使用阴影和高光效果
|
||||
- 第4个:卡通可爱风格,圆润造型,活泼配色
|
||||
- 第5个:现代简约风格,几何与曲线结合,优雅配色
|
||||
4. 细节丰富:不要只用简单的几何图形,添加特征性的细节元素
|
||||
- 例如:餐饮可以加刀叉、蒸汽、食材纹理等
|
||||
- 交通可以加轮胎、车窗、尾气等
|
||||
- 每个图标要有独特的视觉记忆点
|
||||
5. 图标要直观表达分类含义,让人一眼就能识别
|
||||
6. 只返回 JSON 数组格式,包含 5 个完整的 SVG 字符串,不要有任何其他文字说明
|
||||
|
||||
重要:每个 SVG 必须是自包含的完整代码,包含所有必要的 gradient 定义。
|
||||
""";
|
||||
|
||||
var userPrompt = $"""
|
||||
分类名称:{category.Name}
|
||||
分类类型:{typeText}
|
||||
|
||||
请为这个分类生成 5 个精美的、风格各异的彩色 SVG 图标。
|
||||
确保每个图标都有独特的视觉特征,不会与其他图标混淆。
|
||||
|
||||
返回格式(纯 JSON 数组,无其他内容):
|
||||
["<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>", "<svg>...</svg>"]
|
||||
""";
|
||||
|
||||
var response = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 60 * 10);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(response))
|
||||
if (icons == null || icons.Count == 0)
|
||||
{
|
||||
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", category.Name);
|
||||
logger.LogWarning("为分类 {CategoryName}(ID:{CategoryId}) 生成图标失败",
|
||||
category.Name, category.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证返回的是有效的 JSON 数组
|
||||
try
|
||||
{
|
||||
var icons = JsonSerializer.Deserialize<List<string>>(response);
|
||||
if (icons == null || icons.Count != 5)
|
||||
{
|
||||
logger.LogWarning("AI 返回的图标数量不正确(期望5个),分类: {CategoryName}", category.Name);
|
||||
return;
|
||||
}
|
||||
// 保存图标到数据库
|
||||
category.Icon = JsonSerializer.Serialize(icons);
|
||||
await categoryRepository.UpdateAsync(category);
|
||||
|
||||
// 保存图标到数据库
|
||||
category.Icon = response;
|
||||
await categoryRepository.UpdateAsync(category);
|
||||
|
||||
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 5 个图标",
|
||||
category.Name, category.Id);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
|
||||
category.Name, response);
|
||||
}
|
||||
logger.LogInformation("成功为分类 {CategoryName}(ID:{CategoryId}) 生成并保存了 {IconCount} 个图标",
|
||||
category.Name, category.Id, icons.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ public interface ITransactionStatisticsService
|
||||
|
||||
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
|
||||
|
||||
/// <summary>
|
||||
/// 按日期范围获取汇总统计数据(新统一接口)
|
||||
/// </summary>
|
||||
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
|
||||
|
||||
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
|
||||
|
||||
/// <summary>
|
||||
@@ -119,6 +124,44 @@ public class TransactionStatisticsService(
|
||||
return statistics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按日期范围获取汇总统计数据(新统一接口)
|
||||
/// </summary>
|
||||
public async Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var records = await transactionRepository.QueryAsync(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
pageSize: int.MaxValue);
|
||||
|
||||
var statistics = new MonthlyStatistics
|
||||
{
|
||||
Year = startDate.Year,
|
||||
Month = startDate.Month
|
||||
};
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var amount = Math.Abs(record.Amount);
|
||||
|
||||
if (record.Type == TransactionType.Expense)
|
||||
{
|
||||
statistics.TotalExpense += amount;
|
||||
statistics.ExpenseCount++;
|
||||
}
|
||||
else if (record.Type == TransactionType.Income)
|
||||
{
|
||||
statistics.TotalIncome += amount;
|
||||
statistics.IncomeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
statistics.Balance = statistics.TotalIncome - statistics.TotalExpense;
|
||||
statistics.TotalCount = records.Count;
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
public async Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type)
|
||||
{
|
||||
var records = await transactionRepository.QueryAsync(
|
||||
|
||||
Reference in New Issue
Block a user