todo
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-01-19 13:39:59 +08:00
parent 32efbb0324
commit 44d9fbb0f6
8 changed files with 252 additions and 152 deletions

View File

@@ -1,3 +1,4 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ccsvc/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fsql/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strftime/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -0,0 +1,80 @@
namespace Service.Budget;
public interface IBudgetSavingsService
{
Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null);
}
public class BudgetSavingsService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository
) : IBudgetSavingsService
{
public async Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null)
{
var budgets = existingBudgets;
if (existingBudgets == null)
{
budgets = await budgetRepository.GetAllAsync();
}
if (budgets == null)
{
throw new InvalidOperationException("No budgets found.");
}
budgets = budgets
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
.OrderBy(b => b.IsMandatoryExpense)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit);
var year = referenceDate?.Year ?? DateTime.Now.Year;
var month = referenceDate?.Month ?? DateTime.Now.Month;
if(periodType == BudgetPeriodType.Month)
{
return await GetForMonthAsync(budgets, year, month);
}
else if(periodType == BudgetPeriodType.Year)
{
return await GetForYearAsync(budgets, year);
}
throw new NotSupportedException($"Period type {periodType} is not supported.");
}
private async Task<BudgetResult> GetForMonthAsync(
IEnumerable<BudgetRecord> budgets,
int year,
int month)
{
var result = new BudgetResult
{
};
var monthlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Month);
var yearlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Year);
// var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
throw new NotImplementedException();
}
private async Task<BudgetResult> GetForYearAsync(
IEnumerable<BudgetRecord> budgets,
int year)
{
throw new NotImplementedException();
}
}

View File

@@ -1,6 +1,4 @@
using JetBrains.Annotations;
namespace Service;
namespace Service.Budget;
public interface IBudgetService
{

View File

@@ -1,147 +0,0 @@
using HtmlAgilityPack;
namespace Service.EmailServices.EmailParse;
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(), '您的消费明细如下')]");
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 [];
}
// 2. Get Balance (Available Limit)
decimal balance = 0;
// Find "可用额度" label
var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]");
{
// Go up to TR
var tr = limitLabelNode.Ancestors("tr").FirstOrDefault();
if (tr != null)
{
var prevTr = tr.PreviousSibling;
while (prevTr.Name != "tr") prevTr = prevTr.PreviousSibling;
var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]");
var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim();
decimal.TryParse(balanceStr, out balance);
}
}
// 3. Get Transactions
var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']");
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']");
// 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([' '], StringSplitOptions.RemoveEmptyEntries);
string card = "";
string reason = descText;
TransactionType type;
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
{
card = parts[0].Replace("尾号", "");
}
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,164 @@
using HtmlAgilityPack;
// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
namespace Service.EmailServices.EmailParse;
[UsedImplicitly]
public partial class EmailParseFormCcsvc(
ILogger<EmailParseFormCcsvc> logger,
IOpenAiService openAiService
) : EmailParseServicesBase(logger, openAiService)
{
[GeneratedRegex("<.*?>")]
private static partial Regex HtmlRegex();
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标签
return HtmlRegex().IsMatch(body);
}
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 [];
}
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 [];
}
// 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)
{
string card = "";
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([' '], StringSplitOptions.RemoveEmptyEntries);
var reason = descText;
TransactionType type;
if (parts.Length > 0 && parts[0].StartsWith("尾号"))
{
card = parts[0].Replace("尾号", "");
}
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

@@ -15,3 +15,4 @@ global using Microsoft.Extensions.Configuration;
global using Common;
global using System.Net;
global using System.Text.Encodings.Web;
global using JetBrains.Annotations;

View File

@@ -1,4 +1,5 @@
using Quartz;
using Service.Budget;
namespace Service.Jobs;

View File

@@ -1,4 +1,6 @@
namespace WebApi.Controllers;
using Service.Budget;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]