大量的代码格式化
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 38s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
SunCheng
2026-01-18 22:25:59 +08:00
parent 9611ff2088
commit 435efbcb90
18 changed files with 200 additions and 226 deletions

View File

@@ -57,6 +57,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// </summary>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
/// <param name="savingClassify"></param>
/// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
@@ -65,6 +66,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// </summary>
/// <param name="startDate">开始日期</param>
/// <param name="endDate">结束日期</param>
/// <param name="savingClassify"></param>
/// <returns>每天的消费笔数和金额详情</returns>
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
@@ -149,7 +151,6 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
/// <summary>
/// 根据关键词查询交易记录模糊匹配Reason字段
/// </summary>
/// <param name="keyword">关键词</param>
/// <returns>匹配的交易记录列表</returns>
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
@@ -471,7 +472,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
Reason = group.Reason,
Count = group.Count,
SampleType = sample.Type,
SampleClassify = sample.Classify ?? string.Empty,
SampleClassify = sample.Classify,
TransactionIds = records.Select(r => r.Id).ToList(),
TotalAmount = Math.Abs(group.TotalAmount)
});
@@ -549,7 +550,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
.ToListAsync();
var categoryGroups = records
.GroupBy(t => t.Classify ?? "未分类")
.GroupBy(t => t.Classify)
.Select(g => new CategoryStatistics
{
Classify = g.Key,

View File

@@ -43,12 +43,12 @@ public class ConfigService(IConfigRepository configRepository) : IConfigService
var config = await configRepository.GetByKeyAsync(key);
var type = typeof(T) switch
{
Type t when t == typeof(bool) => ConfigType.Boolean,
Type t when t == typeof(int)
{ } t when t == typeof(bool) => ConfigType.Boolean,
{ } t when t == typeof(int)
|| t == typeof(double)
|| t == typeof(float)
|| t == typeof(decimal) => ConfigType.Number,
Type t when t == typeof(string) => ConfigType.String,
{ } t when t == typeof(string) => ConfigType.String,
_ => ConfigType.Json
};
var valueStr = type switch

View File

@@ -1,4 +1,4 @@
using Service.EmailParseServices;
using Service.EmailServices.EmailParse;
namespace Service.EmailServices;

View File

@@ -1,4 +1,4 @@
namespace Service.EmailParseServices;
namespace Service.EmailServices.EmailParse;
public class EmailParseForm95555(
ILogger<EmailParseForm95555> logger,
@@ -26,7 +26,7 @@ public class EmailParseForm95555(
return true;
}
public override async Task<(
public override Task<(
string card,
string reason,
decimal amount,
@@ -51,7 +51,7 @@ public class EmailParseForm95555(
if (matches.Count <= 0)
{
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
return [];
return Task.FromResult<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]>([]);
}
var results = new List<(
@@ -85,7 +85,7 @@ public class EmailParseForm95555(
results.Add((card, reason, amount, balance, type, occurredAt));
}
}
return results.ToArray();
return Task.FromResult(results.ToArray());
}
private DateTime? ParseOccurredAt(string value)

View File

@@ -1,6 +1,6 @@
using HtmlAgilityPack;
namespace Service.EmailParseServices;
namespace Service.EmailServices.EmailParse;
public class EmailParseFormCcsvc(
ILogger<EmailParseFormCcsvc> logger,
@@ -44,11 +44,6 @@ public class EmailParseFormCcsvc(
// 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;您的消费明细如下:"
@@ -63,38 +58,29 @@ public class EmailParseFormCcsvc(
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;
while (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"
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))
@@ -104,11 +90,10 @@ public class EmailParseFormCcsvc(
// 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();
var amountText = amountNode.InnerText.Replace("CNY", "").Replace("&nbsp;", "").Trim();
if (!decimal.TryParse(amountText, out var amount))
{
continue;
@@ -116,7 +101,7 @@ public class EmailParseFormCcsvc(
// Description
var descNode = infoNode.SelectSingleNode(".//tr[2]//font");
var descText = descNode?.InnerText ?? "";
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();
@@ -126,19 +111,13 @@ public class EmailParseFormCcsvc(
string card = "";
string reason = descText;
TransactionType type = TransactionType.Expense;
TransactionType type;
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));
@@ -162,7 +141,6 @@ public class EmailParseFormCcsvc(
logger.LogError(ex, "Error parsing transaction node");
}
}
}
return await Task.FromResult(result.ToArray());
}

View File

@@ -1,4 +1,4 @@
namespace Service.EmailParseServices;
namespace Service.EmailServices.EmailParse;
public interface IEmailParseServices
{

View File

@@ -182,7 +182,7 @@ public class EmailSyncService(
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
foreach (var (message, uid) in unreadMessages)
foreach (var (message, _) in unreadMessages)
{
try
{

View File

@@ -127,7 +127,7 @@ public class EmailSyncJob(
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
foreach (var (message, uid) in unreadMessages)
foreach (var (message, _) in unreadMessages)
{
try
{

View File

@@ -1,5 +1,4 @@
using System.Net;
using WebPush;
using WebPush;
using PushSubscription = Entity.PushSubscription;
namespace Service;

View File

@@ -158,10 +158,8 @@ public class OpenAiService(
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = content
};
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
@@ -232,10 +230,8 @@ public class OpenAiService(
using var content = new StringContent(json, Encoding.UTF8, "application/json");
// 使用 SendAsync 来支持 HttpCompletionOption
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = content
};
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Content = content;
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)

View File

@@ -50,9 +50,6 @@ public class BillImportController(
return "文件大小不能超过 10MB".Fail();
}
// 生成唯一文件名
var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{fileExtension}";
// 保存文件
var ok = false;
var message = string.Empty;
@@ -69,6 +66,11 @@ public class BillImportController(
}
}
if (!ok)
{
return message.Fail();
}
return message.Ok();
}
catch (Exception ex)

View File

@@ -138,7 +138,7 @@ public class BudgetController(
Type = dto.Type,
Limit = limit,
Category = dto.Category,
SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty,
SelectedCategories = string.Join(",", dto.SelectedCategories),
StartDate = dto.StartDate ?? DateTime.Now,
NoLimit = dto.NoLimit,
IsMandatoryExpense = dto.IsMandatoryExpense
@@ -182,7 +182,7 @@ public class BudgetController(
budget.Type = dto.Type;
budget.Limit = limit;
budget.Category = dto.Category;
budget.SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty;
budget.SelectedCategories = string.Join(",", dto.SelectedCategories);
budget.NoLimit = dto.NoLimit;
budget.IsMandatoryExpense = dto.IsMandatoryExpense;
if (dto.StartDate.HasValue)

View File

@@ -31,7 +31,7 @@ public class EmailMessageDto
/// <summary>
/// 从实体转换为DTO
/// </summary>
public static EmailMessageDto FromEntity(Entity.EmailMessage entity, int transactionCount = 0)
public static EmailMessageDto FromEntity(EmailMessage entity, int transactionCount = 0)
{
return new EmailMessageDto
{
@@ -44,7 +44,7 @@ public class EmailMessageDto
CreateTime = entity.CreateTime,
UpdateTime = entity.UpdateTime,
TransactionCount = transactionCount,
ToName = entity.To?.Split('<').FirstOrDefault()?.Trim() ?? "未知"
ToName = entity.To.Split('<').FirstOrDefault()?.Trim() ?? "未知"
};
}
}

View File

@@ -2,7 +2,7 @@
public class PagedResponse<T> : BaseResponse<T[]>
{
public long LastId { get; set; } = 0;
public long LastId { get; set; }
/// <summary>
/// 最后一条记录的时间(用于游标分页)

View File

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

View File

@@ -383,7 +383,8 @@ public class TransactionRecordController(
}
catch (Exception ex)
{
logger.LogError(ex, "获取趋势统计数据失败,开始年份: {Year}, 开始月份: {Month}, 月份数: {Count}", startYear, startMonth, monthCount);
logger.LogError(ex, "获取趋势统计数据失败,开始年份: {Year}, 开始月份: {Month}, 月份数: {Count}", startYear, startMonth,
monthCount);
return $"获取趋势统计数据失败: {ex.Message}".Fail<List<TrendStatistics>>();
}
}
@@ -403,9 +404,16 @@ public class TransactionRecordController(
return;
}
await smartHandleService.AnalyzeBillAsync(request.UserInput, async chunk =>
await smartHandleService.AnalyzeBillAsync(request.UserInput, async void (chunk) =>
{
try
{
await WriteEventAsync(chunk);
}
catch (Exception e)
{
logger.LogError(e, "流式写入账单分析结果失败");
}
});
}
@@ -490,11 +498,18 @@ public class TransactionRecordController(
return;
}
await smartHandleService.SmartClassifyAsync(request.TransactionIds.ToArray(), async chunk =>
await smartHandleService.SmartClassifyAsync(request.TransactionIds.ToArray(), async void (chunk) =>
{
try
{
var (eventType, content) = chunk;
await TrySetUnconfirmedAsync(eventType, content);
await WriteEventAsync(eventType, content);
}
catch (Exception e)
{
logger.LogError(e, "流式写入智能分类结果失败");
}
});
await Response.Body.FlushAsync();
@@ -565,14 +580,17 @@ public class TransactionRecordController(
{
record.Type = item.Type.Value;
}
if (!string.IsNullOrEmpty(record.Classify))
{
record.UnconfirmedClassify = null;
}
if (record.Type == item.Type)
{
record.UnconfirmedType = TransactionType.None;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
successCount++;
@@ -735,18 +753,7 @@ public record CreateTransactionDto(
decimal Amount,
TransactionType Type,
string? Classify
)
{
public string OccurredAt { get; init; } = OccurredAt;
public string? Reason { get; init; } = Reason;
public decimal Amount { get; init; } = Amount;
public TransactionType Type { get; init; } = Type;
public string? Classify { get; init; } = Classify;
}
);
/// <summary>
/// 更新交易记录DTO
@@ -758,20 +765,7 @@ public record UpdateTransactionDto(
decimal Balance,
TransactionType Type,
string? Classify
)
{
public long Id { get; init; } = Id;
public string? Reason { get; init; } = Reason;
public decimal Amount { get; init; } = Amount;
public decimal Balance { get; init; } = Balance;
public TransactionType Type { get; init; } = Type;
public string? Classify { get; init; } = Classify;
}
);
/// <summary>
/// 日历统计响应DTO
@@ -782,28 +776,14 @@ public record DailyStatisticsDto(
decimal Expense,
decimal Income,
decimal Balance
)
{
public string Date { get; init; } = Date;
public int Count { get; init; } = Count;
public decimal Expense { get; init; } = Expense;
public decimal Income { get; init; } = Income;
public decimal Balance { get; init; } = Balance;
}
);
/// <summary>
/// 智能分类请求DTO
/// </summary>
public record SmartClassifyRequest(
List<long>? TransactionIds = null
)
{
public List<long>? TransactionIds { get; init; } = TransactionIds;
}
);
/// <summary>
/// 批量更新分类项DTO
@@ -812,14 +792,7 @@ public record BatchUpdateClassifyItem(
long Id,
string? Classify,
TransactionType? Type = null
)
{
public long Id { get; init; } = Id;
public string? Classify { get; init; } = Classify;
public TransactionType? Type { get; init; } = Type;
}
);
/// <summary>
/// 按摘要批量更新DTO
@@ -828,24 +801,14 @@ public record BatchUpdateByReasonDto(
string Reason,
TransactionType Type,
string Classify
)
{
public string Reason { get; init; } = Reason;
public TransactionType Type { get; init; } = Type;
public string Classify { get; init; } = Classify;
}
);
/// <summary>
/// 账单分析请求DTO
/// </summary>
public record BillAnalysisRequest(
string UserInput
)
{
public string UserInput { get; init; } = UserInput;
}
);
/// <summary>
/// 抵账请求DTO
@@ -853,23 +816,12 @@ public record BillAnalysisRequest(
public record OffsetTransactionDto(
long Id1,
long Id2
)
{
public long Id1 { get; init; } = Id1;
public long Id2 { get; init; } = Id2;
}
);
public record ParseOneLineRequestDto(
string Text
)
{
public string Text { get; init; } = Text;
}
);
public record ConfirmAllUnconfirmedRequestDto(
long[] Ids
)
{
public long[] Ids { get; init; } = Ids;
}
);

View File

@@ -22,21 +22,21 @@ builder.Host.UseSerilog((context, loggerConfig) =>
});
// Add services to the container.
builder.Services.AddControllers(options =>
builder.Services.AddControllers(mvcOptions =>
{
var policy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
mvcOptions.Filters.Add(new AuthorizeFilter(policy));
});
builder.Services.AddOpenApi();
builder.Services.AddHttpClient();
// 配置 CORS
builder.Services.AddCors(options =>
builder.Services.AddCors(corsOptions =>
{
options.AddDefaultPolicy(policy =>
corsOptions.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
@@ -55,14 +55,14 @@ var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["SecretKey"]!;
var key = Encoding.UTF8.GetBytes(secretKey);
builder.Services.AddAuthentication(options =>
builder.Services.AddAuthentication(authenticationOptions =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
authenticationOptions.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
authenticationOptions.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
.AddJwtBearer(jwtBearerOptions =>
{
options.TokenValidationParameters = new TokenValidationParameters
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
@@ -73,7 +73,7 @@ builder.Services.AddAuthentication(options =>
IssuerSigningKey = new SymmetricSecurityKey(key),
ClockSkew = TimeSpan.Zero
};
options.Events = new JwtBearerEvents
jwtBearerOptions.Events = new JwtBearerEvents
{
OnChallenge = async context =>
{

46
qodana.yaml Normal file
View File

@@ -0,0 +1,46 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify IDE code to run analysis without container (Applied in CI/CD pipeline)
ide: QDNET
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50