Files
EmailBill/Service/EmailServices/EmailHandleService.cs
孙诚 f34457a706
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 43s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
feat: 添加深度复制功能,优化自动分类逻辑;更新周期账单视图以显示下次执行时间
2026-01-10 18:04:27 +08:00

398 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
IMessageService messageService,
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 messageService.AddAsync(
"邮件解析失败",
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
url: $"/balance?tab=email"
);
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return true;
}
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 ?? date,
emailMessage.Id,
$"邮件 By {GetEmailByName(to)}"
);
if (record == null)
{
allSuccess = false;
continue;
}
records.Add(record);
}
_ = AutoClassifyAsync(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,
$"邮件 By {GetEmailByName(emailMessage.To)}"
);
if (record == null)
{
allSuccess = false;
continue;
}
records.Add(record);
}
_ = AutoClassifyAsync(records.ToArray());
return allSuccess;
}
private async Task AutoClassifyAsync(TransactionRecord[] records)
{
var clone = records.ToArray().DeepClone();
if(clone?.Any() != true)
{
return;
}
await AnalyzeClassifyAsync(clone);
foreach (var record in records)
{
record.UnconfirmedClassify = record.Classify;
record.UnconfirmedType = record.Type;
record.Classify = ""; // 重置为未分类,等待手动确认
}
await trxRepo.UpdateRangeAsync(records);
// 消息
await messageService.AddAsync(
"交易记录待确认分类",
$"共有 {records.Length} 条交易记录待确认分类,请点击前往确认。",
MessageType.Url,
"/unconfirmed-classification"
);
}
private string GetEmailByName(string to)
{
return emailSettings.Value.SmtpList.FirstOrDefault(s => s.Email == to)?.Name ?? to;
}
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,
string importFrom
)
{
// 根据 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 = 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();
}
}