结构调整
This commit is contained in:
@@ -4,7 +4,7 @@ using MailKit.Search;
|
|||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace Service;
|
namespace Service.EmailServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 邮件抓取服务接口
|
/// 邮件抓取服务接口
|
||||||
@@ -15,7 +15,7 @@ public interface IEmailFetchService
|
|||||||
/// 连接状态
|
/// 连接状态
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 连接到邮件服务器
|
/// 连接到邮件服务器
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -30,7 +30,7 @@ public interface IEmailFetchService
|
|||||||
/// 获取所有邮件
|
/// 获取所有邮件
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync();
|
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 断开与邮件服务器的连接
|
/// 断开与邮件服务器的连接
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -40,7 +40,7 @@ public interface IEmailFetchService
|
|||||||
/// 标记邮件为已读
|
/// 标记邮件为已读
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task MarkAsReadAsync(UniqueId uid);
|
Task MarkAsReadAsync(UniqueId uid);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 确保连接有效,如断开则自动重连
|
/// 确保连接有效,如断开则自动重连
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -61,7 +61,7 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
|
|||||||
private DateTime _lastKeepAlive = DateTime.MinValue;
|
private DateTime _lastKeepAlive = DateTime.MinValue;
|
||||||
private const int KeepAliveIntervalSeconds = 300; // 5分钟发送一次KeepAlive
|
private const int KeepAliveIntervalSeconds = 300; // 5分钟发送一次KeepAlive
|
||||||
private readonly ILogger<EmailFetchService> _logger = logger;
|
private readonly ILogger<EmailFetchService> _logger = logger;
|
||||||
|
|
||||||
public bool IsConnected => _imapClient?.IsConnected == true;
|
public bool IsConnected => _imapClient?.IsConnected == true;
|
||||||
|
|
||||||
public async Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password)
|
public async Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password)
|
||||||
@@ -74,13 +74,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
|
|||||||
_useSsl = useSsl;
|
_useSsl = useSsl;
|
||||||
_email = email;
|
_email = email;
|
||||||
_password = password;
|
_password = password;
|
||||||
|
|
||||||
// 如果已连接,先断开
|
// 如果已连接,先断开
|
||||||
if (_imapClient?.IsConnected == true)
|
if (_imapClient?.IsConnected == true)
|
||||||
{
|
{
|
||||||
await DisconnectAsync();
|
await DisconnectAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
_imapClient = new ImapClient();
|
_imapClient = new ImapClient();
|
||||||
|
|
||||||
if (useSsl)
|
if (useSsl)
|
||||||
@@ -206,7 +206,7 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
|
|||||||
|
|
||||||
// 标记邮件为已读(设置Seen标记)
|
// 标记邮件为已读(设置Seen标记)
|
||||||
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
|
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
|
||||||
|
|
||||||
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
|
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -214,7 +214,7 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
|
|||||||
_logger.LogError(ex, "标记邮件为已读失败: {Message}", ex.Message);
|
_logger.LogError(ex, "标记邮件为已读失败: {Message}", ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 确保连接有效,如果断开则自动重连
|
/// 确保连接有效,如果断开则自动重连
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -240,13 +240,13 @@ public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchS
|
|||||||
}
|
}
|
||||||
return _imapClient?.IsConnected == true;
|
return _imapClient?.IsConnected == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
|
if (string.IsNullOrEmpty(_host) || string.IsNullOrEmpty(_email))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("未初始化连接信息,无法自动重连");
|
_logger.LogWarning("未初始化连接信息,无法自动重连");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
|
_logger.LogInformation("检测到连接断开,尝试重新连接到 {Email}...", _email);
|
||||||
return await ConnectAsync(_host, _port, _useSsl, _email, _password);
|
return await ConnectAsync(_host, _port, _useSsl, _email, _password);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Service.EmailParseServices;
|
using Service.EmailParseServices;
|
||||||
|
|
||||||
namespace Service;
|
namespace Service.EmailServices;
|
||||||
|
|
||||||
public interface IEmailHandleService
|
public interface IEmailHandleService
|
||||||
{
|
{
|
||||||
@@ -72,17 +72,17 @@ public abstract class EmailParseServicesBase(
|
|||||||
)[]?> ParseByAiAsync(string body)
|
)[]?> ParseByAiAsync(string body)
|
||||||
{
|
{
|
||||||
var systemPrompt = $"""
|
var systemPrompt = $"""
|
||||||
你是一个信息抽取助手。
|
你是一个信息抽取助手。
|
||||||
仅输出严格的JSON数组,不要包含任何多余文本。
|
仅输出严格的JSON数组,不要包含任何多余文本。
|
||||||
每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串,yyyy-MM-dd HH:mm:ss格式日期时间)。
|
每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串,yyyy-MM-dd HH:mm:ss格式日期时间)。
|
||||||
如果缺失,请推断或置空。
|
如果缺失,请推断或置空。
|
||||||
[重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
|
[重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
|
||||||
""";
|
""";
|
||||||
var userPrompt = $"""
|
var userPrompt = $"""
|
||||||
从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,
|
从下面这封银行账单相关邮件正文中提取所有交易记录,返回JSON数组格式,
|
||||||
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
|
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
|
||||||
正文如下:\n\n{body}
|
正文如下:\n\n{body}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
|
||||||
if (string.IsNullOrWhiteSpace(contentText))
|
if (string.IsNullOrWhiteSpace(contentText))
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace Service;
|
namespace Service.EmailServices;
|
||||||
|
|
||||||
public interface IEmailBackgroundService
|
public interface IEmailSyncService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 手动触发邮件同步
|
/// 手动触发邮件同步
|
||||||
@@ -11,12 +11,12 @@ public interface IEmailBackgroundService
|
|||||||
Task SyncEmailsAsync();
|
Task SyncEmailsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EmailBackgroundService(
|
public class EmailSyncService(
|
||||||
IOptions<EmailSettings> emailSettings,
|
IOptions<EmailSettings> emailSettings,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IEmailHandleService emailHandleService,
|
IEmailHandleService emailHandleService,
|
||||||
ILogger<EmailBackgroundService> logger)
|
ILogger<EmailSyncService> logger)
|
||||||
: BackgroundWorker, IEmailBackgroundService
|
: BackgroundWorker, IEmailSyncService
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, IEmailFetchService> _emailFetchServices = new();
|
private readonly Dictionary<string, IEmailFetchService> _emailFetchServices = new();
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
@@ -496,4 +496,3 @@ public class ImportService(
|
|||||||
"yyyyMMddHHmmss",
|
"yyyyMMddHHmmss",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using MimeKit;
|
using MimeKit;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Service.EmailServices;
|
||||||
|
|
||||||
namespace Service.Jobs;
|
namespace Service.Jobs;
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
|
|||||||
{
|
{
|
||||||
var message = await messageRepo.GetByIdAsync(id);
|
var message = await messageRepo.GetByIdAsync(id);
|
||||||
if (message == null) return false;
|
if (message == null) return false;
|
||||||
|
|
||||||
message.IsRead = true;
|
message.IsRead = true;
|
||||||
message.UpdateTime = DateTime.Now;
|
message.UpdateTime = DateTime.Now;
|
||||||
return await messageRepo.UpdateAsync(message);
|
return await messageRepo.UpdateAsync(message);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class OpenAiService(
|
|||||||
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var resp = await http.PostAsync(url, content);
|
using var resp = await http.PostAsync(url, content);
|
||||||
@@ -100,7 +100,7 @@ public class OpenAiService(
|
|||||||
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var resp = await http.PostAsync(url, content);
|
using var resp = await http.PostAsync(url, content);
|
||||||
@@ -157,13 +157,13 @@ public class OpenAiService(
|
|||||||
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
{
|
{
|
||||||
Content = content
|
Content = content
|
||||||
};
|
};
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var err = await resp.Content.ReadAsStringAsync();
|
var err = await resp.Content.ReadAsStringAsync();
|
||||||
@@ -230,14 +230,14 @@ public class OpenAiService(
|
|||||||
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
// 使用 SendAsync 来支持 HttpCompletionOption
|
// 使用 SendAsync 来支持 HttpCompletionOption
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
{
|
{
|
||||||
Content = content
|
Content = content
|
||||||
};
|
};
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var err = await resp.Content.ReadAsStringAsync();
|
var err = await resp.Content.ReadAsStringAsync();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class PeriodicBillBackgroundService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
|
|
||||||
// 计算下次执行时间(每天早上6点)
|
// 计算下次执行时间(每天早上6点)
|
||||||
var nextRun = now.Date.AddHours(6);
|
var nextRun = now.Date.AddHours(6);
|
||||||
if (now >= nextRun)
|
if (now >= nextRun)
|
||||||
|
|||||||
@@ -112,33 +112,33 @@ public class SmartHandleService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var systemPrompt = $$"""
|
var systemPrompt = $$"""
|
||||||
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
|
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
|
||||||
|
|
||||||
可用的分类列表:
|
可用的分类列表:
|
||||||
{{categoryInfo}}
|
{{categoryInfo}}
|
||||||
|
|
||||||
分类规则:
|
分类规则:
|
||||||
1. 根据账单的摘要和涉及金额,选择最匹配的分类
|
1. 根据账单的摘要和涉及金额,选择最匹配的分类
|
||||||
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
|
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
|
||||||
3. 如果无法确定分类,可以选择"其他"
|
3. 如果无法确定分类,可以选择"其他"
|
||||||
4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
|
4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类
|
||||||
|
|
||||||
输出格式要求(强制):
|
输出格式要求(强制):
|
||||||
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
- 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。
|
||||||
- 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
|
- 每行的JSON格式严格为:{"reason": "交易摘要", "type": 0, "classify": "分类名称"}
|
||||||
- 不要输出任何解释性文字、编号、标点或多余的文本
|
- 不要输出任何解释性文字、编号、标点或多余的文本
|
||||||
- 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
|
- 如果无法判断分类,请将 "classify" 设为 "其他",并确保仍然输出 JSON 行
|
||||||
|
|
||||||
只输出按行的JSON对象(NDJSON),不要有其他文字说明。
|
只输出按行的JSON对象(NDJSON),不要有其他文字说明。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var userPrompt = $$"""
|
var userPrompt = $$"""
|
||||||
请为以下账单分组进行分类:
|
请为以下账单分组进行分类:
|
||||||
|
|
||||||
{{billsInfo}}
|
{{billsInfo}}
|
||||||
|
|
||||||
请逐个输出分类结果。
|
请逐个输出分类结果。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
// 流式调用AI
|
// 流式调用AI
|
||||||
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
||||||
@@ -253,56 +253,56 @@ public class SmartHandleService(
|
|||||||
// 第一步:使用AI生成聚合SQL查询
|
// 第一步:使用AI生成聚合SQL查询
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var sqlPrompt = $"""
|
var sqlPrompt = $"""
|
||||||
当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
|
当前日期:{now:yyyy年M月d日}({now:yyyy-MM-dd})
|
||||||
用户问题:{userInput}
|
用户问题:{userInput}
|
||||||
|
|
||||||
数据库类型:SQLite
|
数据库类型:SQLite
|
||||||
数据库表名:TransactionRecord
|
数据库表名:TransactionRecord
|
||||||
字段说明:
|
字段说明:
|
||||||
- Id: bigint 主键
|
- Id: bigint 主键
|
||||||
- Card: nvarchar 卡号
|
- Card: nvarchar 卡号
|
||||||
- Reason: nvarchar 交易原因/摘要
|
- Reason: nvarchar 交易原因/摘要
|
||||||
- Amount: decimal 交易金额(支出为负数,收入为正数)
|
- Amount: decimal 交易金额(支出为负数,收入为正数)
|
||||||
- OccurredAt: datetime 交易发生时间(TEXT类型,格式:'2025-12-26 10:30:00')
|
- OccurredAt: datetime 交易发生时间(TEXT类型,格式:'2025-12-26 10:30:00')
|
||||||
- Type: int 交易类型(0=支出, 1=收入, 2=不计入收支)
|
- Type: int 交易类型(0=支出, 1=收入, 2=不计入收支)
|
||||||
- Classify: nvarchar 交易分类(如:交通、餐饮、购物等)
|
- Classify: nvarchar 交易分类(如:交通、餐饮、购物等)
|
||||||
|
|
||||||
【核心原则】直接生成用户所需的聚合统计SQL,而不是查询原始记录后再统计
|
【核心原则】直接生成用户所需的聚合统计SQL,而不是查询原始记录后再统计
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 根据用户问题判断需要什么维度的聚合数据
|
1. 根据用户问题判断需要什么维度的聚合数据
|
||||||
2. 使用 GROUP BY 按分类、时间等维度分组
|
2. 使用 GROUP BY 按分类、时间等维度分组
|
||||||
3. 使用聚合函数:SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小
|
3. 使用聚合函数:SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小
|
||||||
4. 时间范围使用 OccurredAt 字段,"最近X个月/天"基于当前日期计算
|
4. 时间范围使用 OccurredAt 字段,"最近X个月/天"基于当前日期计算
|
||||||
5. 支出用 Type = 0,收入用 Type = 1
|
5. 支出用 Type = 0,收入用 Type = 1
|
||||||
6. 给聚合字段起有意义的别名(如 TotalAmount, TransactionCount, AvgAmount)
|
6. 给聚合字段起有意义的别名(如 TotalAmount, TransactionCount, AvgAmount)
|
||||||
7. 使用 ORDER BY 对结果排序(通常按金额降序)
|
7. 使用 ORDER BY 对结果排序(通常按金额降序)
|
||||||
8. 只返回SQL语句,不要解释
|
8. 只返回SQL语句,不要解释
|
||||||
|
|
||||||
【重要】SQLite日期函数:
|
【重要】SQLite日期函数:
|
||||||
- 提取年份:strftime('%Y', OccurredAt)
|
- 提取年份:strftime('%Y', OccurredAt)
|
||||||
- 提取月份:strftime('%m', OccurredAt)
|
- 提取月份:strftime('%m', OccurredAt)
|
||||||
- 提取日期:strftime('%Y-%m-%d', OccurredAt)
|
- 提取日期:strftime('%Y-%m-%d', OccurredAt)
|
||||||
- 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
|
- 不要使用 YEAR()、MONTH()、DAY() 函数,SQLite不支持
|
||||||
|
|
||||||
示例1(按分类统计):
|
示例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
|
返回: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(按月统计):
|
示例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
|
返回: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(总体统计):
|
示例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'
|
返回: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(详细记录 - 仅在用户明确要求详情时使用):
|
示例4(详细记录 - 仅在用户明确要求详情时使用):
|
||||||
用户:单笔超过1000元的支出有哪些?
|
用户:单笔超过1000元的支出有哪些?
|
||||||
返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
|
返回:SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
|
||||||
|
|
||||||
只返回SQL语句。
|
只返回SQL语句。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var sqlText = await openAiService.ChatAsync(sqlPrompt);
|
var sqlText = await openAiService.ChatAsync(sqlPrompt);
|
||||||
|
|
||||||
@@ -339,39 +339,39 @@ public class SmartHandleService(
|
|||||||
});
|
});
|
||||||
|
|
||||||
var dataPrompt = $"""
|
var dataPrompt = $"""
|
||||||
当前日期:{DateTime.Now:yyyy年M月d日}
|
当前日期:{DateTime.Now:yyyy年M月d日}
|
||||||
用户问题:{userInput}
|
用户问题:{userInput}
|
||||||
|
|
||||||
查询结果数据(JSON格式):
|
查询结果数据(JSON格式):
|
||||||
{dataJson}
|
{dataJson}
|
||||||
|
|
||||||
说明:以上数据是根据用户问题查询出的聚合统计结果,请基于这些数据生成分析报告。
|
说明:以上数据是根据用户问题查询出的聚合统计结果,请基于这些数据生成分析报告。
|
||||||
|
|
||||||
请生成一份专业的数据分析报告,严格遵守以下要求:
|
请生成一份专业的数据分析报告,严格遵守以下要求:
|
||||||
|
|
||||||
【格式要求】
|
【格式要求】
|
||||||
1. 使用HTML格式(移动端H5页面风格)
|
1. 使用HTML格式(移动端H5页面风格)
|
||||||
2. 生成清晰的报告标题(基于用户问题)
|
2. 生成清晰的报告标题(基于用户问题)
|
||||||
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
|
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td)
|
||||||
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
||||||
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
||||||
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
||||||
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
||||||
|
|
||||||
【样式限制(重要)】
|
【样式限制(重要)】
|
||||||
8. 不要包含 html、body、head 标签
|
8. 不要包含 html、body、head 标签
|
||||||
9. 不要使用任何 style 属性或 <style> 标签
|
9. 不要使用任何 style 属性或 <style> 标签
|
||||||
10. 不要设置 background、background-color、color 等样式属性
|
10. 不要设置 background、background-color、color 等样式属性
|
||||||
11. 不要使用 div 包裹大段内容
|
11. 不要使用 div 包裹大段内容
|
||||||
|
|
||||||
【内容要求】
|
【内容要求】
|
||||||
12. 准确解读数据:将JSON数据转换为易读的表格和文字说明
|
12. 准确解读数据:将JSON数据转换为易读的表格和文字说明
|
||||||
13. 提供洞察分析:根据数据给出有价值的发现和趋势分析
|
13. 提供洞察分析:根据数据给出有价值的发现和趋势分析
|
||||||
14. 给出实用建议:基于数据提供合理的财务建议
|
14. 给出实用建议:基于数据提供合理的财务建议
|
||||||
15. 语言专业、清晰、简洁
|
15. 语言专业、清晰、简洁
|
||||||
|
|
||||||
直接输出纯净的HTML内容,不要markdown代码块标记。
|
直接输出纯净的HTML内容,不要markdown代码块标记。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
// 第四步:流式输出AI分析结果
|
// 第四步:流式输出AI分析结果
|
||||||
await foreach (var chunk in openAiService.ChatStreamAsync(dataPrompt))
|
await foreach (var chunk in openAiService.ChatStreamAsync(dataPrompt))
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_segmenter = new JiebaSegmenter();
|
_segmenter = new JiebaSegmenter();
|
||||||
_extractor = new TfidfExtractor();
|
_extractor = new TfidfExtractor();
|
||||||
|
|
||||||
// 仅添加JiebaNet词典中可能缺失的特定业务词汇
|
// 仅添加JiebaNet词典中可能缺失的特定业务词汇
|
||||||
AddCustomWords();
|
AddCustomWords();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ public class TransactionPeriodicService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogInformation("开始执行周期性账单检查...");
|
logger.LogInformation("开始执行周期性账单检查...");
|
||||||
|
|
||||||
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
|
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
|
||||||
var billsList = pendingBills.ToList();
|
var billsList = pendingBills.ToList();
|
||||||
|
|
||||||
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
|
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
|
||||||
|
|
||||||
foreach (var bill in billsList)
|
foreach (var bill in billsList)
|
||||||
@@ -61,7 +61,7 @@ public class TransactionPeriodicService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
var success = await transactionRepository.AddAsync(transaction);
|
var success = await transactionRepository.AddAsync(transaction);
|
||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
|
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
|
||||||
@@ -80,7 +80,7 @@ public class TransactionPeriodicService(
|
|||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
var nextTime = CalculateNextExecuteTime(bill, now);
|
var nextTime = CalculateNextExecuteTime(bill, now);
|
||||||
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
|
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
|
||||||
|
|
||||||
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
|
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
|
||||||
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
|
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ public class TransactionPeriodicService(
|
|||||||
private bool ShouldExecuteToday(TransactionPeriodic bill)
|
private bool ShouldExecuteToday(TransactionPeriodic bill)
|
||||||
{
|
{
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
// 如果从未执行过,需要执行
|
// 如果从未执行过,需要执行
|
||||||
if (bill.LastExecuteTime == null)
|
if (bill.LastExecuteTime == null)
|
||||||
{
|
{
|
||||||
@@ -224,7 +224,7 @@ public class TransactionPeriodicService(
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var currentDayOfWeek = (int)baseTime.DayOfWeek;
|
var currentDayOfWeek = (int)baseTime.DayOfWeek;
|
||||||
|
|
||||||
// 找下一个执行日
|
// 找下一个执行日
|
||||||
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
|
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
|
||||||
if (nextDay > 0)
|
if (nextDay > 0)
|
||||||
@@ -232,7 +232,7 @@ public class TransactionPeriodicService(
|
|||||||
var daysToAdd = nextDay - currentDayOfWeek;
|
var daysToAdd = nextDay - currentDayOfWeek;
|
||||||
return baseTime.Date.AddDays(daysToAdd);
|
return baseTime.Date.AddDays(daysToAdd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下周的第一个执行日
|
// 下周的第一个执行日
|
||||||
var firstDay = executeDays.First();
|
var firstDay = executeDays.First();
|
||||||
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
|
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
|
||||||
@@ -281,7 +281,7 @@ public class TransactionPeriodicService(
|
|||||||
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
|
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
|
||||||
var nextQuarterStartMonth = currentQuarterStartMonth + 3;
|
var nextQuarterStartMonth = currentQuarterStartMonth + 3;
|
||||||
var nextQuarterYear = baseTime.Year;
|
var nextQuarterYear = baseTime.Year;
|
||||||
|
|
||||||
if (nextQuarterStartMonth > 12)
|
if (nextQuarterStartMonth > 12)
|
||||||
{
|
{
|
||||||
nextQuarterStartMonth = 1;
|
nextQuarterStartMonth = 1;
|
||||||
@@ -306,7 +306,7 @@ public class TransactionPeriodicService(
|
|||||||
// 处理闰年情况
|
// 处理闰年情况
|
||||||
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
|
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
|
||||||
var actualDay = Math.Min(dayOfYear, daysInYear);
|
var actualDay = Math.Min(dayOfYear, daysInYear);
|
||||||
|
|
||||||
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
|
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace WebApi.Controllers.EmailMessage;
|
using Service.EmailServices;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers.EmailMessage;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
@@ -7,7 +9,7 @@ public class EmailMessageController(
|
|||||||
ITransactionRecordRepository transactionRepository,
|
ITransactionRecordRepository transactionRepository,
|
||||||
ILogger<EmailMessageController> logger,
|
ILogger<EmailMessageController> logger,
|
||||||
IEmailHandleService emailHandleService,
|
IEmailHandleService emailHandleService,
|
||||||
IEmailBackgroundService emailBackgroundService
|
IEmailSyncService emailBackgroundService
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user