first commot
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 8s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s

This commit is contained in:
孙诚
2025-12-25 11:20:56 +08:00
commit 4526cc6396
104 changed files with 11070 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
namespace Service.AppSettingModel;
public class AISettings
{
public string Endpoint { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,14 @@
namespace Service.AppSettingModel;
/// <summary>
/// 邮箱配置项
/// </summary>
public class EmailConfigItem
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string ImapHost { get; set; } = string.Empty;
public int ImapPort { get; set; } = 993;
public bool UseSsl { get; set; } = true;
}

View File

@@ -0,0 +1,11 @@
namespace Service.AppSettingModel;
/// <summary>
/// 邮箱设置配置
/// </summary>
public class EmailSettings
{
public int CheckIntervalMinutes { get; set; } = 1;
public EmailConfigItem[] SmtpList { get; set; } = [];
public string[] FilterFromAddresses { get; set; } = [];
}

View File

@@ -0,0 +1,236 @@
using System.ComponentModel;
using MimeKit;
namespace Service;
public interface IEmailBackgroundService
{
/// <summary>
/// 手动触发邮件同步
/// </summary>
Task SyncEmailsAsync();
}
public class EmailBackgroundService(
IOptions<EmailSettings> emailSettings,
IServiceProvider serviceProvider,
IEmailHandleService emailHandleService,
ILogger<EmailBackgroundService> logger)
: BackgroundWorker, IEmailBackgroundService
{
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(
message.From.ToString(),
message.Subject,
message.Date.DateTime,
message.TextBody ?? message.HtmlBody ?? string.Empty
))
{
// 标记邮件为已读
await emailFetchService.MarkAsReadAsync(uid);
}
}
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", "");
}
}

View File

@@ -0,0 +1,253 @@
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using MailKit.Security;
using MimeKit;
namespace Service;
/// <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,275 @@
using Service.EmailParseServices;
namespace Service;
public interface IEmailHandleService
{
Task<bool> HandleEmailAsync(
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
) : IEmailHandleService
{
public async Task<bool> HandleEmailAsync(
string from,
string subject,
DateTime date,
string body
)
{
var emailMessage = await SaveEmailAsync(from, subject, date, body);
if (emailMessage == null)
{
throw new InvalidOperationException("邮件保存失败,无法继续处理");
}
var filterForm = emailSettings.Value.FilterFromAddresses;
if (filterForm.Length == 0)
{
logger.LogWarning("未配置邮件过滤条件,跳过账单处理");
return true;
}
if (!filterForm.Any(f => from.Contains(f)))
{
logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理");
return true;
}
var parsed = await ParseEmailBodyAsync(
from,
string.IsNullOrEmpty(emailMessage.Body)
? emailMessage.HtmlBody
: emailMessage.Body
);
if (parsed == null || parsed.Length == 0)
{
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
return true;
}
logger.LogInformation("成功解析邮件,共 {Count} 条交易记录", parsed.Length);
bool allSuccess = true;
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{
logger.LogInformation("处理交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
var success = await SaveTransactionRecordAsync(
card,
reason,
amount,
balance,
type,
occurredAt ?? date,
emailMessage.Id
);
if (!success)
{
allSuccess = false;
}
}
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,
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;
foreach (var (card, reason, amount, balance, type, occurredAt) in parsed)
{
logger.LogInformation("刷新交易记录: 卡号 {Card}, 交易原因 {Reason}, 金额 {Amount}, 余额 {Balance}, 类型 {Type}", card, reason, amount, balance, type);
var success = await SaveTransactionRecordAsync(
card,
reason,
amount,
balance,
type,
occurredAt ?? emailMessage.ReceivedDate,
emailMessage.Id
);
if (!success)
{
allSuccess = false;
}
}
return allSuccess;
}
private async Task<EmailMessage?> SaveEmailAsync(
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 existsEmail = await emailRepo.ExistsAsync(from, subject, date, body);
if (existsEmail != null)
{
logger.LogInformation("检测到重复邮件,跳过入库:{From} | {Subject} | {Date}", from, subject, date);
return existsEmail;
}
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<bool> 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 updated;
}
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 inserted;
}
private async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailBodyAsync(string from, string body)
{
var service = emailParsers.FirstOrDefault(s => s.CanParse(from, body));
if (service == null)
{
logger.LogWarning("未找到合适的邮件解析服务,跳过解析");
return null;
}
return await service.ParseAsync(body);
}
}

View File

@@ -0,0 +1,71 @@
namespace Service.EmailParseServices;
public class EmailParseForm95555(
ILogger<EmailParseForm95555> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
{
public override bool CanParse(string from, string body)
{
if (!from.Contains("95555@message.cmbchina.com"))
{
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 pattern = "您账户(?<card>\\d+)于.*?(?<type>收入|支出|消费|转入|转出)?.*?在?(?<reason>.+?)(?<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 reason = match.Groups["reason"].Value;
var amountStr = match.Groups["amount"].Value;
var balanceStr = match.Groups["balance"].Value;
var typeStr = match.Groups["type"].Value;
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);
results.Add((card, reason, amount, balance, type, null));
}
}
return results.ToArray();
}
}

View File

@@ -0,0 +1,153 @@
using HtmlAgilityPack;
namespace Service.EmailParseServices;
public class EmailParseFormCCSVC(
ILogger<EmailParseFormCCSVC> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
{
public override bool CanParse(string from, string body)
{
if (!from.Contains("ccsvc@message.cmbchina.com"))
{
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));
}
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,211 @@
namespace Service.EmailParseServices;
public interface IEmailParseServices
{
bool CanParse(string from, 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 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格式日期时间)。如果缺失,请推断或置空。";
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 = { "工资", "奖金", "退款", "返现", "收入", "转入", "存入", "利息", "分红" };
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;
}
}

13
Service/GlobalUsings.cs Normal file
View File

@@ -0,0 +1,13 @@
global using Repository;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using System.Text.RegularExpressions;
global using Microsoft.Extensions.Options;
global using System.Globalization;
global using System.Text;
global using System.Text.Json;
global using Entity;
global using FreeSql;
global using System.Linq;
global using System.Security.Cryptography;
global using Service.AppSettingModel;

499
Service/ImportService.cs Normal file
View File

@@ -0,0 +1,499 @@
using CsvHelper;
using CsvHelper.Configuration;
using OfficeOpenXml;
namespace Service;
public interface IImportService
{
/// <summary>
/// 导入支付宝账单
/// </summary>
Task<(bool ok, string message)> ImportAlipayAsync(MemoryStream file, string fileExtension);
/// <summary>
/// 导入微信账单
/// </summary>
Task<(bool ok, string message)> ImportWeChatAsync(MemoryStream file, string fileExtension);
}
public class ImportService(
ILogger<ImportService> logger,
ITransactionRecordRepository transactionRecordRepository
) : IImportService
{
public async Task<(bool ok, string message)> ImportAlipayAsync(MemoryStream file, string fileExtension)
{
var content = await ParseAsync(file, fileExtension);
logger.LogInformation("转换后的支付宝账单数据行数: {RowCount}", content.Length);
if (content.Length == 0)
{
logger.LogWarning("支付宝账单文件解析后无数据行");
return (false, "支付宝账单文件解析后无数据行");
}
var addTransactionRecords = new List<TransactionRecord>();
var updateTransactionRecords = new List<TransactionRecord>();
foreach (var row in content)
{
var importNo = row.ContainsKey("交易号") ? row["交易号"] : string.Empty;
if (string.IsNullOrWhiteSpace(importNo))
{
logger.LogWarning("跳过无交易号的记录: {Row}", row);
continue;
}
var existingRecord = await transactionRecordRepository.ExistsByImportNoAsync(importNo, "支付宝");
if (existingRecord != null)
{
existingRecord.Reason = GetReason(row);
existingRecord.Amount = GetDecimalValue(row, "金额(元)");
existingRecord.RefundAmount = GetDecimalValue(row, "成功退款(元)");
existingRecord.OccurredAt = GetDateTimeValue(row, "交易创建时间");
existingRecord.Type = GetTransactionType(row, "收/支");
updateTransactionRecords.Add(existingRecord);
continue;
}
var transactionRecord = new TransactionRecord
{
Reason = GetReason(row),
Amount = GetDecimalValue(row, "金额(元)"),
RefundAmount = GetDecimalValue(row, "成功退款(元)"),
Balance = 0,
OccurredAt = GetDateTimeValue(row, "交易创建时间"),
Type = GetTransactionType(row, "收/支"),
ImportNo = importNo,
ImportFrom = "支付宝"
};
addTransactionRecords.Add(transactionRecord);
}
if (addTransactionRecords.Count == 0 && updateTransactionRecords.Count == 0)
{
logger.LogWarning("未找到可导入或更新的支付宝交易记录");
return (false, "未找到可导入或更新的支付宝交易记录");
}
var message = new StringBuilder();
if (addTransactionRecords.Count > 0)
{
if (await transactionRecordRepository.AddRangeAsync(addTransactionRecords))
{
logger.LogInformation("成功导入支付宝交易记录数: {Count}", addTransactionRecords.Count);
message.AppendLine($"成功导入支付宝交易记录数: {addTransactionRecords.Count}");
}
}
if (updateTransactionRecords.Count > 0)
{
if (await transactionRecordRepository.UpdateRangeAsync(updateTransactionRecords))
{
logger.LogInformation("成功更新支付宝交易记录数: {Count}", updateTransactionRecords.Count);
message.AppendLine($"成功更新支付宝交易记录数: {updateTransactionRecords.Count}");
}
}
return (true, message.ToString());
string GetReason(IDictionary<string, string> row)
{
var reason = string.Empty;
if (row.ContainsKey("交易对方") && !string.IsNullOrWhiteSpace(row["交易对方"]))
{
reason += row["交易对方"];
}
if (row.ContainsKey("商品名称") && !string.IsNullOrWhiteSpace(row["商品名称"]))
{
reason += row["商品名称"];
}
return reason;
}
decimal GetDecimalValue(IDictionary<string, string> row, string key)
{
if (row.ContainsKey(key) && decimal.TryParse(row[key], out var value))
{
return value;
}
return 0m;
}
DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
{
if (!row.ContainsKey(key))
{
return DateTime.MinValue;
}
foreach (var format in DateTimeFormats)
{
if (DateTime.TryParseExact(
row[key],
format,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var value))
{
return value;
}
}
if (DateTime.TryParse(row[key], out var value2))
{
return value2;
}
return DateTime.MinValue;
}
TransactionType GetTransactionType(IDictionary<string, string> row, string key)
{
if (!row.ContainsKey(key))
{
return TransactionType.None;
}
var typeStr = row[key];
return typeStr switch
{
"支出" => TransactionType.Expense,
"收入" => TransactionType.Income,
_ => TransactionType.None
};
}
}
public async Task<(bool ok, string message)> ImportWeChatAsync(MemoryStream file, string fileExtension)
{
var content = await ParseAsync(file, fileExtension);
logger.LogInformation("转换后的微信账单数据行数: {RowCount}", content.Length);
if (content.Length == 0)
{
logger.LogWarning("微信账单文件解析后无数据行");
return (false, "微信账单文件解析后无数据行");
}
var addTransactionRecords = new List<TransactionRecord>();
var updateTransactionRecords = new List<TransactionRecord>();
foreach (var row in content)
{
var importNo = row.ContainsKey("交易单号") ? row["交易单号"] : string.Empty;
if (string.IsNullOrWhiteSpace(importNo))
{
logger.LogWarning("跳过无交易单号的记录: {Row}", row);
continue;
}
var existingRecord = await transactionRecordRepository.ExistsByImportNoAsync(importNo, "微信");
if (existingRecord != null)
{
existingRecord.Reason = GetReason(row);
existingRecord.Amount = GetAmountValue(row, "金额(元)");
existingRecord.OccurredAt = GetDateTimeValue(row, "交易时间");
existingRecord.Type = GetTransactionType(row, "收/支");
existingRecord.RefundAmount = GetRefundAmountValue(row);
updateTransactionRecords.Add(existingRecord);
continue;
}
var transactionRecord = new TransactionRecord
{
Reason = GetReason(row),
Amount = GetAmountValue(row, "金额(元)"),
RefundAmount = GetRefundAmountValue(row),
Balance = 0,
OccurredAt = GetDateTimeValue(row, "交易时间"),
Type = GetTransactionType(row, "收/支"),
ImportNo = importNo,
ImportFrom = "微信"
};
addTransactionRecords.Add(transactionRecord);
}
if (addTransactionRecords.Count == 0 && updateTransactionRecords.Count == 0)
{
logger.LogWarning("未找到可导入或更新的微信交易记录");
return (false, "未找到可导入或更新的微信交易记录");
}
var message = new StringBuilder();
if (addTransactionRecords.Count > 0)
{
if (await transactionRecordRepository.AddRangeAsync(addTransactionRecords))
{
logger.LogInformation("成功导入微信交易记录数: {Count}", addTransactionRecords.Count);
message.AppendLine($"成功导入微信交易记录数: {addTransactionRecords.Count}");
}
}
if (updateTransactionRecords.Count > 0)
{
if (await transactionRecordRepository.UpdateRangeAsync(updateTransactionRecords))
{
logger.LogInformation("成功更新微信交易记录数: {Count}", updateTransactionRecords.Count);
message.AppendLine($"成功更新微信交易记录数: {updateTransactionRecords.Count}");
}
}
return (true, message.ToString());
string GetReason(IDictionary<string, string> row)
{
var reason = string.Empty;
if (row.ContainsKey("交易类型") && !string.IsNullOrWhiteSpace(row["交易类型"]))
{
reason += row["交易类型"];
}
if (row.ContainsKey("交易对方") && !string.IsNullOrWhiteSpace(row["交易对方"]))
{
reason += row["交易对方"];
}
if (row.ContainsKey("商品") && !string.IsNullOrWhiteSpace(row["商品"]))
{
reason += row["商品"];
}
return reason;
}
decimal GetAmountValue(IDictionary<string, string> row, string key)
{
if (row.ContainsKey(key) && decimal.TryParse(row[key].TrimStart('¥').TrimStart('¥'), out var value))
{
return value;
}
return 0m;
}
DateTime GetDateTimeValue(IDictionary<string, string> row, string key)
{
if(!row.ContainsKey(key))
{
return DateTime.MinValue;
}
foreach (var format in DateTimeFormats)
{
if (DateTime.TryParseExact(
row[key],
format,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var value))
{
return value;
}
}
if (DateTime.TryParse(row[key], out var value2))
{
return value2;
}
return DateTime.MinValue;
}
TransactionType GetTransactionType(IDictionary<string, string> row, string key)
{
if (!row.ContainsKey(key))
{
return TransactionType.None;
}
var typeStr = row[key];
return typeStr switch
{
"支出" => TransactionType.Expense,
"收入" => TransactionType.Income,
_ => TransactionType.None
};
}
decimal GetRefundAmountValue(IDictionary<string, string> row)
{
if (!row.ContainsKey("当前状态"))
{
return 0m;
}
var status = row["当前状态"];
if (!status.Contains("退款"))
{
return 0m;
}
// 使用正则表达式提取退款金额
var regex = new System.Text.RegularExpressions.Regex(@"¥(-?\d+(\.\d+)?)");
var match = regex.Match(status);
if (match.Success && decimal.TryParse(match.Groups[1].Value, out var refundAmount))
{
return refundAmount;
}
return 0m;
}
}
private async Task<IDictionary<string, string>[]> ParseAsync(MemoryStream file, string fileExtension)
{
if (fileExtension == ".csv")
{
return await ParseCsvAsync(file);
}
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
{
return await ParseExcelAsync(file);
}
else
{
throw new NotSupportedException("不支持的文件格式");
}
}
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
{
file.Position = 0;
using var reader = new StreamReader(file, Encoding.UTF8);
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
TrimOptions = TrimOptions.Trim
};
using var csv = new CsvReader(reader, config);
// 读取表头
await csv.ReadAsync();
csv.ReadHeader();
var headers = csv.HeaderRecord;
if (headers == null || headers.Length == 0)
{
return Array.Empty<IDictionary<string, string>>();
}
var result = new List<IDictionary<string, string>>();
// 读取数据行
while (await csv.ReadAsync())
{
var row = new Dictionary<string, string>();
foreach (var header in headers)
{
row[header] = csv.GetField(header) ?? string.Empty;
}
result.Add(row);
}
return result.ToArray();
}
private async Task<IDictionary<string, string>[]> ParseExcelAsync(MemoryStream file)
{
file.Position = 0;
// 设置 EPPlus 许可证上下文
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
using var package = new ExcelPackage(file);
var worksheet = package.Workbook.Worksheets.FirstOrDefault();
if (worksheet == null || worksheet.Dimension == null)
{
return Array.Empty<IDictionary<string, string>>();
}
var rowCount = worksheet.Dimension.End.Row;
var colCount = worksheet.Dimension.End.Column;
if (rowCount < 2)
{
return Array.Empty<IDictionary<string, string>>();
}
// 读取表头(第一行)
var headers = new List<string>();
for (int col = 1; col <= colCount; col++)
{
var header = worksheet.Cells[1, col].Text?.Trim() ?? string.Empty;
headers.Add(header);
}
var result = new List<IDictionary<string, string>>();
// 读取数据行(从第二行开始)
for (int row = 2; row <= rowCount; row++)
{
var rowData = new Dictionary<string, string>();
for (int col = 1; col <= colCount; col++)
{
var header = headers[col - 1];
var value = worksheet.Cells[row, col].Text?.Trim() ?? string.Empty;
rowData[header] = value;
}
result.Add(rowData);
}
return await Task.FromResult(result.ToArray());
}
private static string[] DateTimeFormats =
[
"yyyy-MM-dd",
"yyyy-MM-dd HH",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy-M-d",
"yyyy-M-d HH:mm",
"yyyy-M-d HH:mm:ss",
"yyyy/MM/dd",
"yyyy/MM/dd HH",
"yyyy/MM/dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy/M/d",
"yyyy/M/d HH:mm",
"yyyy/M/d HH:mm:ss",
"MM/dd/yyyy",
"MM/dd/yyyy HH",
"MM/dd/yyyy HH:mm",
"MM/dd/yyyy HH:mm:ss",
"M/d/yyyy",
"M/d/yyyy HH:mm",
"M/d/yyyy HH:mm:ss",
"MM/dd/yy",
"M/d/yy H:mm",
"MM/dd/yy HH",
"MM/dd/yy HH:mm",
"MM/dd/yy HH:mm:ss",
"M/d/yy",
"M/d/yy HH",
"M/d/yy HH:mm",
"M/d/yy HH:mm:ss",
"yyyyMMdd",
"yyyyMMddHH",
"yyyyMMddHHmm",
"yyyyMMddHHmmss",
];
}

71
Service/OpenAiService.cs Normal file
View File

@@ -0,0 +1,71 @@
using System.Net.Http.Headers;
namespace Service;
public interface IOpenAiService
{
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
}
public class OpenAiService(
IOptions<AISettings> aiSettings,
ILogger<OpenAiService> logger
) : IOpenAiService
{
public async Task<string?> ChatAsync(string systemPrompt, string userPrompt)
{
var cfg = aiSettings.Value;
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
string.IsNullOrWhiteSpace(cfg.Key) ||
string.IsNullOrWhiteSpace(cfg.Model))
{
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
return null;
}
using var http = new HttpClient();
http.Timeout = TimeSpan.FromSeconds(15);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
var payload = new
{
model = cfg.Model,
temperature = 0,
messages = new object[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
using var resp = await http.PostAsync(url, content);
if (!resp.IsSuccessStatusCode)
{
var err = await resp.Content.ReadAsStringAsync();
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
}
var respText = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respText);
var root = doc.RootElement;
var contentText = root.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
return contentText;
}
catch (Exception ex)
{
logger.LogError(ex, "AI 调用失败");
throw;
}
}
}

21
Service/Service.csproj Normal file
View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Repository\Repository.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MailKit" />
<PackageReference Include="MimeKit" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="EPPlus" />
<PackageReference Include="HtmlAgilityPack" />
</ItemGroup>
</Project>