结构调整
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 14s
Docker Build & Deploy / Deploy to Production (push) Has been skipped

This commit is contained in:
2026-01-01 12:32:08 +08:00
parent c1aa4df4f3
commit 8dfe7f1688
15 changed files with 152 additions and 150 deletions

View File

@@ -0,0 +1,253 @@
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MailKit.Security;
using MimeKit;
namespace Service.EmailServices;
/// <summary>
/// 邮件抓取服务接口
/// </summary>
public interface IEmailFetchService
{
/// <summary>
/// 连接状态
/// </summary>
bool IsConnected { get; }
/// <summary>
/// 连接到邮件服务器
/// </summary>
Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password);
/// <summary>
/// 从收件箱获取未读邮件
/// </summary>
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchUnreadMessagesAsync();
/// <summary>
/// 获取所有邮件
/// </summary>
Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync();
/// <summary>
/// 断开与邮件服务器的连接
/// </summary>
Task DisconnectAsync();
/// <summary>
/// 标记邮件为已读
/// </summary>
Task MarkAsReadAsync(UniqueId uid);
/// <summary>
/// 确保连接有效,如断开则自动重连
/// </summary>
Task<bool> EnsureConnectedAsync();
}
/// <summary>
/// 邮件抓取服务实现
/// </summary>
public class EmailFetchService(ILogger<EmailFetchService> logger) : IEmailFetchService
{
private ImapClient? _imapClient;
private string _host = string.Empty;
private int _port;
private bool _useSsl;
private string _email = string.Empty;
private string _password = string.Empty;
private DateTime _lastKeepAlive = DateTime.MinValue;
private const int KeepAliveIntervalSeconds = 300; // 5分钟发送一次KeepAlive
private readonly ILogger<EmailFetchService> _logger = logger;
public bool IsConnected => _imapClient?.IsConnected == true;
public async Task<bool> ConnectAsync(string host, int port, bool useSsl, string email, string password)
{
try
{
// 保存连接信息用于自动重连
_host = host;
_port = port;
_useSsl = useSsl;
_email = email;
_password = password;
// 如果已连接,先断开
if (_imapClient?.IsConnected == true)
{
await DisconnectAsync();
}
_imapClient = new ImapClient();
if (useSsl)
{
await _imapClient.ConnectAsync(host, port, SecureSocketOptions.SslOnConnect);
}
else
{
await _imapClient.ConnectAsync(host, port, SecureSocketOptions.StartTlsWhenAvailable);
}
await _imapClient.AuthenticateAsync(email, password);
_logger.LogInformation("邮箱 {Email} 连接成功", email);
_lastKeepAlive = DateTime.UtcNow;
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "邮件连接失败 ({Email}): {Message}", email, ex.Message);
return false;
}
}
public async Task<List<(MimeMessage Message, UniqueId Uid)>> FetchUnreadMessagesAsync()
{
var result = new List<(MimeMessage, UniqueId)>();
try
{
// 确保连接有效
if (!await EnsureConnectedAsync())
return result;
var inbox = _imapClient?.Inbox;
if (inbox == null)
return result;
await inbox.OpenAsync(FolderAccess.ReadWrite);
// 查询未读邮件
var unreadUids = await inbox.SearchAsync(SearchQuery.NotSeen);
foreach (var uid in unreadUids)
{
var message = await inbox.GetMessageAsync(uid);
result.Add((message, uid));
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "获取未读邮件失败: {Message}", ex.Message);
return result;
}
}
public async Task<List<(MimeMessage Message, UniqueId Uid)>> FetchAllMessagesAsync()
{
var result = new List<(MimeMessage, UniqueId)>();
try
{
// 确保连接有效
if (!await EnsureConnectedAsync())
return result;
var inbox = _imapClient?.Inbox;
if (inbox == null)
return result;
await inbox.OpenAsync(FolderAccess.ReadWrite);
var uids = await inbox.SearchAsync(SearchQuery.All);
foreach (var uid in uids)
{
var message = await inbox.GetMessageAsync(uid);
result.Add((message, uid));
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "获取所有邮件失败: {Message}", ex.Message);
return result;
}
}
public async Task DisconnectAsync()
{
try
{
if (_imapClient?.IsConnected == true)
{
await _imapClient.DisconnectAsync(true);
_logger.LogInformation("邮箱 {Email} 已断开连接", _email);
}
_imapClient?.Dispose();
_imapClient = null;
}
catch (Exception ex)
{
_logger.LogError(ex, "断开连接失败 ({Email}): {Message}", _email, ex.Message);
}
}
public async Task MarkAsReadAsync(UniqueId uid)
{
try
{
if (!await EnsureConnectedAsync())
return;
var inbox = _imapClient?.Inbox;
if (inbox == null)
return;
// 打开收件箱以读写模式
await inbox.OpenAsync(FolderAccess.ReadWrite);
// 标记邮件为已读设置Seen标记
await inbox.AddFlagsAsync(uid, MessageFlags.Seen, silent: false);
_logger.LogDebug("邮件 {Uid} 标记已读操作已提交", uid);
}
catch (Exception ex)
{
_logger.LogError(ex, "标记邮件为已读失败: {Message}", ex.Message);
}
}
/// <summary>
/// 确保连接有效,如果断开则自动重连
/// </summary>
public async Task<bool> EnsureConnectedAsync()
{
if (_imapClient?.IsConnected == true)
{
// 定期发送NOOP保持连接活跃防止超时断开
var timeSinceLastKeepAlive = (DateTime.UtcNow - _lastKeepAlive).TotalSeconds;
if (timeSinceLastKeepAlive > KeepAliveIntervalSeconds)
{
try
{
await _imapClient.NoOpAsync();
_lastKeepAlive = DateTime.UtcNow;
_logger.LogDebug("邮箱 {Email} KeepAlive 保活信号已发送", _email);
}
catch (Exception ex)
{
// NOOP失败说明连接已断开继续重连逻辑
_logger.LogWarning(ex, "KeepAlive 失败,连接已断开: {Message}", ex.Message);
}
}
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);
}
}

View File

@@ -0,0 +1,366 @@
using Service.EmailParseServices;
namespace Service.EmailServices;
public interface IEmailHandleService
{
Task<bool> HandleEmailAsync(
string to,
string from,
string subject,
DateTime date,
string body
);
Task<bool> RefreshTransactionRecordsAsync(long emailMessageId);
}
public class EmailHandleService(
IOptions<EmailSettings> emailSettings,
ILogger<EmailHandleService> logger,
IEmailMessageRepository emailRepo,
ITransactionRecordRepository trxRepo,
IEnumerable<IEmailParseServices> emailParsers,
IMessageRecordService messageRecordService,
ISmartHandleService smartHandleService
) : IEmailHandleService
{
public async Task<bool> HandleEmailAsync(
string to,
string from,
string subject,
DateTime date,
string body
)
{
var filterForm = emailSettings.Value.FilterFromAddresses;
if (filterForm.Length == 0)
{
logger.LogWarning("未配置邮件过滤条件,跳过账单处理");
return false;
}
if (!filterForm.Any(f => from.Contains(f)))
{
logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理");
return false;
}
var emailMessage = await SaveEmailAsync(to, from, subject, date, body);
if (emailMessage == null)
{
throw new InvalidOperationException("邮件保存失败,无法继续处理");
}
var parsed = await ParseEmailBodyAsync(
from,
subject,
string.IsNullOrEmpty(emailMessage.Body)
? emailMessage.HtmlBody
: emailMessage.Body
);
if (parsed == null || parsed.Length == 0)
{
await messageRecordService.AddAsync(
"邮件解析失败",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。"
);
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return true;
}
await messageRecordService.AddAsync(
"邮件解析成功",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})已成功解析出 {parsed.Length} 条交易记录。"
);
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
// TODO 接入AI分类
// 目前已经
bool allSuccess = true;
var records = new List<TransactionRecord>();
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{
logger.LogInformation("处理交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
var record = await SaveTransactionRecordAsync(
card,
reason,
amount,
balance,
type,
occurredAt ?? date,
emailMessage.Id
);
if (record == null)
{
allSuccess = false;
continue;
}
records.Add(record);
}
_ = await AnalyzeClassifyAsync(records.ToArray());
return allSuccess;
}
public async Task<bool> RefreshTransactionRecordsAsync(long emailMessageId)
{
var emailMessage = await emailRepo.GetByIdAsync(emailMessageId);
if (emailMessage == null)
{
logger.LogWarning("未找到指定ID的邮件记录无法刷新交易记录ID: {Id}", emailMessageId);
return false;
}
var filterForm = emailSettings.Value.FilterFromAddresses;
if (filterForm.Length == 0)
{
logger.LogWarning("未配置邮件过滤条件,跳过账单处理");
return false;
}
if (!filterForm.Any(f => emailMessage.From.Contains(f)))
{
logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理");
return true;
}
var parsed = await ParseEmailBodyAsync(
emailMessage.From,
emailMessage.Subject,
string.IsNullOrEmpty(emailMessage.Body)
? emailMessage.HtmlBody
: emailMessage.Body
);
if (parsed == null || parsed.Length == 0)
{
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return false;
}
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
bool allSuccess = true;
var records = new List<TransactionRecord>();
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{
logger.LogInformation("刷新交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
var record = await SaveTransactionRecordAsync(
card,
reason,
amount,
balance,
type,
occurredAt ?? emailMessage.ReceivedDate,
emailMessage.Id
);
if (record == null)
{
allSuccess = false;
continue;
}
records.Add(record);
}
_ = await AnalyzeClassifyAsync(records.ToArray());
return allSuccess;
}
private async Task<EmailMessage?> SaveEmailAsync(
string to,
string from,
string subject,
DateTime date,
string body
)
{
var emailEntity = new EmailMessage
{
From = from,
Subject = subject,
ReceivedDate = date,
};
// 正则判断是否为HTML内容
if (Regex.IsMatch(body, @"<[^>]+>"))
{
emailEntity.HtmlBody = body;
}
else
{
emailEntity.Body = body;
}
try
{
var emailMd5 = emailEntity.ComputeBodyHash();
var existsEmail = await emailRepo.ExistsAsync(emailMd5);
if (existsEmail != null)
{
logger.LogInformation("检测到重复邮件,跳过入库:{From} | {Subject} | {Date}", from, subject, date);
return existsEmail;
}
emailEntity.Md5 = emailMd5;
var toName = emailSettings.Value.SmtpList
.FirstOrDefault(s => s.Email == to)?.Name ?? "";
emailEntity.To = string.IsNullOrEmpty(toName) ? to : $"{toName} <{to}>";
var ok = await emailRepo.AddAsync(emailEntity);
if (ok)
{
logger.LogInformation("邮件已落库ID: {Id}", emailEntity.Id);
return emailEntity;
}
logger.LogError("邮件落库失败");
return null;
}
catch (Exception ex)
{
// 原始邮件落库失败不阻塞交易记录,但记录告警
logger.LogWarning(ex, "原始邮件落库失败");
return null;
}
}
private async Task<TransactionRecord?> SaveTransactionRecordAsync(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime occurredAt,
long emailMessageId
)
{
// 根据 emailMessageId 检查是否已存在记录:存在则更新,否则新增
var existing = await trxRepo.ExistsByEmailMessageIdAsync(emailMessageId, occurredAt);
if (existing != null)
{
existing.Card = card;
existing.Reason = reason;
existing.Amount = amount;
existing.Balance = balance;
existing.Type = type;
existing.OccurredAt = occurredAt;
var updated = await trxRepo.UpdateAsync(existing);
if (updated)
{
logger.LogInformation("交易记录已更新,卡号 {Card}, 金额 {Amount}", card, amount);
}
else
{
logger.LogWarning("交易记录更新失败,卡号 {Card}, 金额 {Amount}", card, amount);
return null;
}
return existing;
}
var trx = new TransactionRecord
{
Card = card,
Reason = reason,
Amount = amount,
Balance = balance,
Type = type,
OccurredAt = occurredAt,
EmailMessageId = emailMessageId,
ImportFrom = $"邮件"
};
var inserted = await trxRepo.AddAsync(trx);
if (inserted)
{
logger.LogInformation("交易记录已落库,卡号 {Card}, 金额 {Amount}", card, amount);
}
else
{
logger.LogWarning("交易记录落库失败,卡号 {Card}, 金额 {Amount}", card, amount);
return null;
}
return trx;
}
private async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailBodyAsync(string from, string subject, string body)
{
var service = emailParsers.FirstOrDefault(s => s.CanParse(from, subject, body));
if (service == null)
{
logger.LogWarning("未找到合适的邮件解析服务,跳过解析");
return null;
}
return await service.ParseAsync(body);
}
private async Task<TransactionRecord[]> AnalyzeClassifyAsync(TransactionRecord[] records)
{
var result = new List<TransactionRecord>();
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<JsonObject>(data);
var recordId = item?["id"]?.GetValue<long>();
var classify = item?["Classify"]?.GetValue<string>();
var recordType = item?["Type"]?.GetValue<int>();
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();
}
}

View File

@@ -0,0 +1,107 @@
namespace Service.EmailParseServices;
public class EmailParseForm95555(
ILogger<EmailParseForm95555> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
{
public override bool CanParse(string from, string subject, string body)
{
if (!from.Contains("95555@message.cmbchina.com"))
{
return false;
}
if (!subject.Contains("账户变动通知"))
{
return false;
}
// 不能包含HTML标签
if (Regex.IsMatch(body, "<.*?>"))
{
return false;
}
return true;
}
public override async Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)[]> ParseEmailContentAsync(string emailContent)
{
// 示例1您账户8826于12月31日09:34在财付通-微信支付-这有电快捷支付1.00元余额30.21
// 示例2: 您账户8826于12月31日10:47入账款项人民币1000.00余额人民币1030.21。
var pattern =
"您账户(?<card>\\d+)" + // 卡号
"于(?<time>\\d{1,2}月\\d{1,2}日\\d{1,2}:\\d{2})" + // 交易时间
"(?:(?<type>收入|支出|消费|转入|转出|入账款项))?" + // 交易类型(可选)
"(?:在(?<reason>[^\\d。]*?))?" + // 交易原因(可选)
"?(?:人民币)?(?<amount>\\d+\\.\\d{1,2})(?:元)?" + // 金额,“元” 可有可无
",余额(?:人民币)?(?<balance>\\d+\\.\\d{1,2})" + // 余额
"。?"; // 句号可有可无
var matches = Regex.Matches(emailContent, pattern);
if (matches.Count <= 0)
{
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
return [];
}
var results = new List<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)>();
foreach (Match match in matches)
{
var card = match.Groups["card"].Value;
var amountStr = match.Groups["amount"].Value;
var balanceStr = match.Groups["balance"].Value;
var typeStr = match.Groups["type"].Value;
var reason = match.Groups["reason"].Value;
if(string.IsNullOrEmpty(reason))
{
reason = typeStr;
}
if (!string.IsNullOrEmpty(card) &&
!string.IsNullOrEmpty(reason) &&
decimal.TryParse(amountStr, out var amount) &&
decimal.TryParse(balanceStr, out var balance))
{
var type = DetermineTransactionType(typeStr, reason, amount);
var occurredAt = ParseOccurredAt(match.Groups["time"].Value);
results.Add((card, reason, amount, balance, type, occurredAt));
}
}
return results.ToArray();
}
private DateTime? ParseOccurredAt(string value)
{
// "12月31日09:34"
var now = DateTime.Now;
var dateTimeStr = $"{now.Year}年{value}";
if (DateTime.TryParse(dateTimeStr, out var occurredAt))
{
// 如果解析结果在未来,说明是上一年的交易
if (occurredAt > now)
{
occurredAt = occurredAt.AddYears(-1);
}
return occurredAt;
}
return null;
}
}

View File

@@ -0,0 +1,169 @@
using HtmlAgilityPack;
namespace Service.EmailParseServices;
public class EmailParseFormCCSVC(
ILogger<EmailParseFormCCSVC> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
{
public override bool CanParse(string from, string subject, string body)
{
if (!from.Contains("ccsvc@message.cmbchina.com"))
{
return false;
}
if (!subject.Contains("每日信用管家"))
{
return false;
}
// 必须包含HTML标签
if (!Regex.IsMatch(body, "<.*?>"))
{
return false;
}
return true;
}
public override async Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)[]> ParseEmailContentAsync(string emailContent)
{
var doc = new HtmlDocument();
doc.LoadHtml(emailContent);
var result = new List<(string, string, decimal, decimal, TransactionType, DateTime?)>();
// 1. Get Date
var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]");
if (dateNode == null)
{
logger.LogWarning("Date node not found");
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
}
var dateText = dateNode.InnerText.Trim();
// "2025/12/21&nbsp;您的消费明细如下:"
var dateMatch = Regex.Match(dateText, @"\d{4}/\d{1,2}/\d{1,2}");
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
{
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
}
// 2. Get Balance (Available Limit)
decimal balance = 0;
// Find "可用额度" label
var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]");
if (limitLabelNode != null)
{
// Go up to TR
var tr = limitLabelNode.Ancestors("tr").FirstOrDefault();
if (tr != null)
{
var prevTr = tr.PreviousSibling;
while (prevTr != null && prevTr.Name != "tr") prevTr = prevTr.PreviousSibling;
if (prevTr != null)
{
var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]");
if (balanceNode != null)
{
var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim();
decimal.TryParse(balanceStr, out balance);
}
}
}
}
// 3. Get Transactions
var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']");
if (transactionNodes != null)
{
foreach (var node in transactionNodes)
{
try
{
// Time
var timeNode = node.SelectSingleNode(".//span[@id='fixBand5']//font");
var timeText = timeNode?.InnerText.Trim(); // "10:13:43"
DateTime? occurredAt = date;
if (!string.IsNullOrEmpty(timeText) && DateTime.TryParse($"{date:yyyy-MM-dd} {timeText}", out var dt))
{
occurredAt = dt;
}
// Info Block
var infoNode = node.SelectSingleNode(".//span[@id='fixBand12']");
if (infoNode == null) continue;
// Amount
var amountNode = infoNode.SelectSingleNode(".//font[contains(text(), 'CNY')]");
var amountText = amountNode?.InnerText.Replace("CNY", "").Replace("&nbsp;", "").Trim();
if (!decimal.TryParse(amountText, out var amount))
{
continue;
}
// Description
var descNode = infoNode.SelectSingleNode(".//tr[2]//font");
var descText = descNode?.InnerText ?? "";
// Replace &nbsp; and non-breaking space (\u00A0) with normal space
descText = descText.Replace("&nbsp;", " ");
descText = HtmlEntity.DeEntitize(descText).Replace((char)160, ' ').Trim();
// Parse Description: "尾号4390 消费 财付通-luckincoffee瑞幸咖啡"
var parts = descText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
string card = "";
string reason = descText;
TransactionType type = TransactionType.Expense;
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
{
card = parts[0].Replace("尾号", "");
}
if (parts.Length > 1)
{
var typeStr = parts[1];
type = DetermineTransactionType(typeStr, reason, amount);
}
if (parts.Length > 2)
{
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)
{
logger.LogError(ex, "Error parsing transaction node");
}
}
}
return await Task.FromResult(result.ToArray());
}
}

View File

@@ -0,0 +1,285 @@
namespace Service.EmailParseServices;
public interface IEmailParseServices
{
bool CanParse(string from, string subject, string body);
/// <summary>
/// 解析邮件内容,提取交易信息
/// </summary>
Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)[]> ParseAsync(string emailContent);
}
public abstract class EmailParseServicesBase(
ILogger<EmailParseServicesBase> logger,
IOpenAiService openAiService
) : IEmailParseServices
{
public abstract bool CanParse(string from, string subject, string body);
public async Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
DateTime? occurredAt
)[]> ParseAsync(string emailContent)
{
var result = await ParseEmailContentAsync(emailContent);
if (result.Length > 0)
{
logger.LogInformation("使用规则成功解析邮件内容,提取到 {Count} 条交易记录", result.Length);
return result;
}
logger.LogInformation("规则解析邮件内容失败尝试使用AI进行解析");
// AI兜底
result = await ParseByAiAsync(emailContent) ?? [];
if(result.Length == 0)
{
logger.LogWarning("AI解析邮件内容也未能提取到任何交易记录");
}
return result;
}
public abstract Task<(
string card,
string reason,
decimal amount,
decimal balance,
TransactionType type,
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)
{
string card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
string reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
string typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
string occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
decimal 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;
}
decimal 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)
{
// 优先使用明确的类型字符串
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;
}
}

View File

@@ -0,0 +1,241 @@
using System.ComponentModel;
using MimeKit;
namespace Service.EmailServices;
public interface IEmailSyncService
{
/// <summary>
/// 手动触发邮件同步
/// </summary>
Task SyncEmailsAsync();
}
public class EmailSyncService(
IOptions<EmailSettings> emailSettings,
IServiceProvider serviceProvider,
IEmailHandleService emailHandleService,
ILogger<EmailSyncService> logger)
: BackgroundWorker, IEmailSyncService
{
private readonly Dictionary<string, IEmailFetchService> _emailFetchServices = new();
private bool _isInitialized;
protected override async void OnDoWork(DoWorkEventArgs e)
{
try
{
// 启动时建立所有连接
await InitializeConnectionsAsync();
while (!CancellationPending)
{
try
{
await FetchAndPostCmbTransactionsAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "后台任务执行出错");
}
// 使用 Thread.Sleep 在异步操作中不阻塞
Thread.Sleep(1000 * 60 * 10); // 每10分钟执行一次任务
}
}
catch (Exception ex)
{
logger.LogError(ex, "后台服务工作线程出错");
}
finally
{
// 停止时断开所有连接
try
{
await DisconnectAllAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "断开连接时出错");
}
}
}
/// <summary>
/// 初始化所有邮箱连接
/// </summary>
private async Task InitializeConnectionsAsync()
{
if (_isInitialized)
{
logger.LogWarning("连接已初始化,跳过重复初始化");
return;
}
try
{
if (emailSettings.Value.SmtpList.Length == 0)
{
logger.LogWarning("未配置邮箱账户,无法初始化连接");
return;
}
logger.LogInformation("开始初始化 {EmailCount} 个邮箱连接...", emailSettings.Value.SmtpList.Length);
// 并行初始化所有邮箱连接
var tasks = emailSettings.Value.SmtpList.Select(async emailConfig =>
{
try
{
var emailFetchService = ActivatorUtilities.CreateInstance<EmailFetchService>(serviceProvider);
var success = await emailFetchService.ConnectAsync(
emailConfig.ImapHost,
emailConfig.ImapPort,
emailConfig.UseSsl,
emailConfig.Email,
emailConfig.Password);
if (success)
{
_emailFetchServices[emailConfig.Email] = emailFetchService;
logger.LogInformation("邮箱 {Email} 连接建立成功", emailConfig.Email);
}
else
{
logger.LogError("邮箱 {Email} 连接建立失败", emailConfig.Email);
}
}
catch (Exception ex)
{
logger.LogError(ex, "初始化邮箱 {Email} 连接时出错", emailConfig.Email);
}
});
await Task.WhenAll(tasks);
_isInitialized = true;
logger.LogInformation("所有邮箱连接初始化完成,成功连接 {Count} 个邮箱", _emailFetchServices.Count);
}
catch (Exception ex)
{
logger.LogError(ex, "初始化邮箱连接失败");
}
}
/// <summary>
/// 断开所有邮箱连接
/// </summary>
private async Task DisconnectAllAsync()
{
logger.LogInformation("开始断开所有邮箱连接...");
var tasks = _emailFetchServices.Select(async kvp =>
{
try
{
await kvp.Value.DisconnectAsync();
logger.LogInformation("邮箱 {Email} 已断开连接", kvp.Key);
}
catch (Exception ex)
{
logger.LogError(ex, "断开邮箱 {Email} 连接时出错", kvp.Key);
}
});
await Task.WhenAll(tasks);
_emailFetchServices.Clear();
_isInitialized = false;
logger.LogInformation("所有邮箱连接已断开");
}
/// <summary>
/// 手动触发邮件同步(公开方法)
/// </summary>
public async Task SyncEmailsAsync()
{
await FetchAndPostCmbTransactionsAsync();
}
/// <summary>
/// 抓取并处理招商银行邮件交易
/// </summary>
private async Task FetchAndPostCmbTransactionsAsync()
{
try
{
if (_emailFetchServices.Count == 0)
{
logger.LogWarning("没有可用的邮箱连接,跳过抓取");
return;
}
logger.LogInformation("开始抓取 {EmailCount} 个邮箱的邮件", _emailFetchServices.Count);
// 并行处理多个邮箱
var tasks = _emailFetchServices.Select(async kvp =>
{
var email = kvp.Key;
var emailFetchService = kvp.Value;
try
{
// 获取未读邮件
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
foreach (var (message, uid) in unreadMessages)
{
try
{
logger.LogDebug("邮件信息 - 发送者: {From}, 主题: {Subject}, 接收时间: {Date}",
message.From, message.Subject, message.Date);
logger.LogDebug("邮件内容预览: {Preview}", GetEmailBodyPreview(message));
if (await emailHandleService.HandleEmailAsync(
email,
message.From.ToString(),
message.Subject,
message.Date.DateTime,
message.TextBody ?? message.HtmlBody ?? string.Empty
) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3)))
{
#if DEBUG
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
#else
// 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid);
#endif
}
}
catch (Exception ex)
{
logger.LogError(ex, "处理邮件时出错");
}
}
logger.LogInformation("邮箱 {Email} 邮件抓取完成", email);
}
catch (Exception ex)
{
logger.LogError(ex, "邮箱 {Email} 邮件抓取失败", email);
}
});
await Task.WhenAll(tasks);
logger.LogInformation("所有邮箱邮件抓取完成");
}
catch (Exception ex)
{
logger.LogError(ex, "抓取邮件异常");
}
}
/// <summary>
/// 获取邮件内容预览
/// </summary>
private static string GetEmailBodyPreview(MimeMessage message)
{
var body = message.HtmlBody ?? message.TextBody ?? string.Empty;
var preview = body.Length > 100 ? body.Substring(0, 100) + "..." : body;
return preview.Replace("\n", " ").Replace("\r", "");
}
}