Compare commits
3 Commits
298ce03aa6
...
435efbcb90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435efbcb90 | ||
|
|
9611ff2088 | ||
|
|
4e2bf0da6c |
3
Common/GlobalUsings.cs
Normal file
3
Common/GlobalUsings.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
global using System.Reflection;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Reflection;
|
namespace Common;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Common;
|
|
||||||
|
|
||||||
public static class TypeExtensions
|
public static class TypeExtensions
|
||||||
{
|
{
|
||||||
@@ -10,8 +7,8 @@ public static class TypeExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static T? DeepClone<T>(this T source)
|
public static T? DeepClone<T>(this T source)
|
||||||
{
|
{
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(source);
|
var json = JsonSerializer.Serialize(source);
|
||||||
return System.Text.Json.JsonSerializer.Deserialize<T>(json);
|
return JsonSerializer.Deserialize<T>(json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +38,7 @@ public static class ServiceExtension
|
|||||||
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
private static void RegisterServices(IServiceCollection services, Assembly assembly)
|
||||||
{
|
{
|
||||||
var types = assembly.GetTypes()
|
var types = assembly.GetTypes()
|
||||||
.Where(t => t.IsClass && !t.IsAbstract);
|
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||||
|
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
@@ -71,14 +68,13 @@ public static class ServiceExtension
|
|||||||
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
private static void RegisterRepositories(IServiceCollection services, Assembly assembly)
|
||||||
{
|
{
|
||||||
var types = assembly.GetTypes()
|
var types = assembly.GetTypes()
|
||||||
.Where(t => t.IsClass && !t.IsAbstract);
|
.Where(t => t is { IsClass: true, IsAbstract: false });
|
||||||
|
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
var interfaces = type.GetInterfaces()
|
var interfaces = type.GetInterfaces()
|
||||||
.Where(i => i.Name.StartsWith("I")
|
.Where(i => i.Name.StartsWith("I")
|
||||||
&& i.Namespace == "Repository"
|
&& i is { Namespace: "Repository", IsGenericType: false }); // 排除泛型接口如 IBaseRepository<T>
|
||||||
&& !i.IsGenericType); // 排除泛型接口如 IBaseRepository<T>
|
|
||||||
|
|
||||||
foreach (var @interface in interfaces)
|
foreach (var @interface in interfaces)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<!-- Email & MIME Libraries -->
|
<!-- Email & MIME Libraries -->
|
||||||
<PackageVersion Include="FreeSql" Version="3.5.305" />
|
<PackageVersion Include="FreeSql" Version="3.5.305" />
|
||||||
<PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" />
|
<PackageVersion Include="FreeSql.Extensions.JsonMap" Version="3.5.305" />
|
||||||
|
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
|
||||||
<PackageVersion Include="MailKit" Version="4.14.1" />
|
<PackageVersion Include="MailKit" Version="4.14.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||||
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
<PackageVersion Include="MimeKit" Version="4.14.0" />
|
||||||
@@ -34,5 +35,10 @@
|
|||||||
<!-- Text Processing -->
|
<!-- Text Processing -->
|
||||||
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
<PackageVersion Include="JiebaNet.Analyser" Version="1.0.6" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<!-- Testing -->
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Entity", "Entity\Entity.csproj", "{B1BCD944-C4F5-406E-AE66-864E4BA21522}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.Test", "WebApi.Test\WebApi.Test.csproj", "{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -83,6 +85,18 @@ Global
|
|||||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU
|
{B1BCD944-C4F5-406E-AE66-864E4BA21522}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9CD457C8-A985-4DEA-9774-3B1D33CAFF51}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
<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">
|
<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/=fsql/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<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>
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Security.Cryptography;
|
namespace Entity;
|
||||||
|
|
||||||
namespace Entity;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 邮件消息实体
|
/// 邮件消息实体
|
||||||
@@ -39,7 +37,7 @@ public class EmailMessage : BaseEntity
|
|||||||
public string ComputeBodyHash()
|
public string ComputeBodyHash()
|
||||||
{
|
{
|
||||||
using var md5 = MD5.Create();
|
using var md5 = MD5.Create();
|
||||||
var inputBytes = System.Text.Encoding.UTF8.GetBytes(Body + HtmlBody);
|
var inputBytes = Encoding.UTF8.GetBytes(Body + HtmlBody);
|
||||||
var hashBytes = md5.ComputeHash(inputBytes);
|
var hashBytes = md5.ComputeHash(inputBytes);
|
||||||
return Convert.ToHexString(hashBytes);
|
return Convert.ToHexString(hashBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
global using FreeSql.DataAnnotations;
|
global using FreeSql.DataAnnotations;
|
||||||
|
global using System.Security.Cryptography;
|
||||||
|
global using System.Text;
|
||||||
@@ -12,6 +12,6 @@ public class PushSubscription : BaseEntity
|
|||||||
public string? Auth { get; set; }
|
public string? Auth { get; set; }
|
||||||
|
|
||||||
public string? UserId { get; set; } // Optional: if you have user authentication
|
public string? UserId { get; set; } // Optional: if you have user authentication
|
||||||
|
|
||||||
public string? UserAgent { get; set; }
|
public string? UserAgent { get; set; }
|
||||||
}
|
}
|
||||||
@@ -170,10 +170,10 @@ public abstract class BaseRepository<T>(IFreeSql freeSql) : IBaseRepository<T> w
|
|||||||
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
|
||||||
var result = new List<dynamic>();
|
var result = new List<dynamic>();
|
||||||
|
|
||||||
foreach (System.Data.DataRow row in dt.Rows)
|
foreach (DataRow row in dt.Rows)
|
||||||
{
|
{
|
||||||
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
|
var expando = new ExpandoObject() as IDictionary<string, object>;
|
||||||
foreach (System.Data.DataColumn column in dt.Columns)
|
foreach (DataColumn column in dt.Columns)
|
||||||
{
|
{
|
||||||
expando[column.ColumnName] = row[column];
|
expando[column.ColumnName] = row[column];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
global using Entity;
|
global using Entity;
|
||||||
global using FreeSql;
|
|
||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
|
global using System.Data;
|
||||||
|
global using System.Dynamic;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="year">年份</param>
|
/// <param name="year">年份</param>
|
||||||
/// <param name="month">月份</param>
|
/// <param name="month">月份</param>
|
||||||
|
/// <param name="savingClassify"></param>
|
||||||
/// <returns>每天的消费笔数和金额详情</returns>
|
/// <returns>每天的消费笔数和金额详情</returns>
|
||||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
|
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>
|
/// </summary>
|
||||||
/// <param name="startDate">开始日期</param>
|
/// <param name="startDate">开始日期</param>
|
||||||
/// <param name="endDate">结束日期</param>
|
/// <param name="endDate">结束日期</param>
|
||||||
|
/// <param name="savingClassify"></param>
|
||||||
/// <returns>每天的消费笔数和金额详情</returns>
|
/// <returns>每天的消费笔数和金额详情</returns>
|
||||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
|
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>
|
/// <summary>
|
||||||
/// 根据关键词查询交易记录(模糊匹配Reason字段)
|
/// 根据关键词查询交易记录(模糊匹配Reason字段)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="keyword">关键词</param>
|
|
||||||
/// <returns>匹配的交易记录列表</returns>
|
/// <returns>匹配的交易记录列表</returns>
|
||||||
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
|
Task<List<TransactionRecord>> QueryByWhereAsync(string sql);
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (classifies != null && classifies.Length > 0)
|
if (classifies is { Length: > 0 })
|
||||||
{
|
{
|
||||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
@@ -290,15 +291,13 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.Page(pageIndex, pageSize)
|
.Page(pageIndex, pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
// 按时间降序排列
|
||||||
// 按时间降序排列
|
return await query
|
||||||
return await query
|
.OrderByDescending(t => t.OccurredAt)
|
||||||
.OrderByDescending(t => t.OccurredAt)
|
.OrderByDescending(t => t.Id)
|
||||||
.OrderByDescending(t => t.Id)
|
.Page(pageIndex, pageSize)
|
||||||
.Page(pageIndex, pageSize)
|
.ToListAsync();
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<long> GetTotalCountAsync(
|
public async Task<long> GetTotalCountAsync(
|
||||||
@@ -323,7 +322,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
t => t.Reason == reason);
|
t => t.Reason == reason);
|
||||||
|
|
||||||
// 按分类筛选
|
// 按分类筛选
|
||||||
if (classifies != null && classifies.Length > 0)
|
if (classifies is { Length: > 0 })
|
||||||
{
|
{
|
||||||
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList();
|
||||||
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
query = query.Where(t => filterClassifies.Contains(t.Classify));
|
||||||
@@ -471,9 +470,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
result.Add(new ReasonGroupDto
|
result.Add(new ReasonGroupDto
|
||||||
{
|
{
|
||||||
Reason = group.Reason,
|
Reason = group.Reason,
|
||||||
Count = (int)group.Count,
|
Count = group.Count,
|
||||||
SampleType = sample.Type,
|
SampleType = sample.Type,
|
||||||
SampleClassify = sample.Classify ?? string.Empty,
|
SampleClassify = sample.Classify,
|
||||||
TransactionIds = records.Select(r => r.Id).ToList(),
|
TransactionIds = records.Select(r => r.Id).ToList(),
|
||||||
TotalAmount = Math.Abs(group.TotalAmount)
|
TotalAmount = Math.Abs(group.TotalAmount)
|
||||||
});
|
});
|
||||||
@@ -551,7 +550,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var categoryGroups = records
|
var categoryGroups = records
|
||||||
.GroupBy(t => t.Classify ?? "未分类")
|
.GroupBy(t => t.Classify)
|
||||||
.Select(g => new CategoryStatistics
|
.Select(g => new CategoryStatistics
|
||||||
{
|
{
|
||||||
Classify = g.Key,
|
Classify = g.Key,
|
||||||
@@ -615,9 +614,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
|
public async Task<List<TransactionRecord>> GetClassifiedByKeywordsAsync(List<string> keywords, int limit = 10)
|
||||||
{
|
{
|
||||||
if (keywords == null || keywords.Count == 0)
|
if (keywords.Count == 0)
|
||||||
{
|
{
|
||||||
return new List<TransactionRecord>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = FreeSql.Select<TransactionRecord>()
|
var query = FreeSql.Select<TransactionRecord>()
|
||||||
@@ -637,9 +636,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10)
|
public async Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10)
|
||||||
{
|
{
|
||||||
if (keywords == null || keywords.Count == 0)
|
if (keywords.Count == 0)
|
||||||
{
|
{
|
||||||
return new List<(TransactionRecord, double)>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询所有已分类且包含任意关键词的账单
|
// 查询所有已分类且包含任意关键词的账单
|
||||||
@@ -687,7 +686,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
if (currentRecord == null)
|
if (currentRecord == null)
|
||||||
{
|
{
|
||||||
return new List<TransactionRecord>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var list = await FreeSql.Select<TransactionRecord>()
|
var list = await FreeSql.Select<TransactionRecord>()
|
||||||
@@ -740,7 +739,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
var query = FreeSql.Select<TransactionRecord>()
|
var query = FreeSql.Select<TransactionRecord>()
|
||||||
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type);
|
.Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type);
|
||||||
|
|
||||||
if (classifies != null && classifies.Any())
|
if (classifies.Any())
|
||||||
{
|
{
|
||||||
query = query.Where(t => classifies.Contains(t.Classify));
|
query = query.Where(t => classifies.Contains(t.Classify));
|
||||||
}
|
}
|
||||||
@@ -753,12 +752,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
|
.GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1))
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return list
|
||||||
return list
|
.GroupBy(t => t.OccurredAt.Date)
|
||||||
.GroupBy(t => t.OccurredAt.Date)
|
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,7 +787,7 @@ public class ReasonGroupDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 该分组的所有账单ID列表
|
/// 该分组的所有账单ID列表
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<long> TransactionIds { get; set; } = new();
|
public List<long> TransactionIds { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 该分组的总金额(绝对值)
|
/// 该分组的总金额(绝对值)
|
||||||
@@ -804,12 +801,19 @@ public class ReasonGroupDto
|
|||||||
public class MonthlyStatistics
|
public class MonthlyStatistics
|
||||||
{
|
{
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
|
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
||||||
public decimal TotalExpense { get; set; }
|
public decimal TotalExpense { get; set; }
|
||||||
|
|
||||||
public decimal TotalIncome { get; set; }
|
public decimal TotalIncome { get; set; }
|
||||||
|
|
||||||
public decimal Balance { get; set; }
|
public decimal Balance { get; set; }
|
||||||
|
|
||||||
public int ExpenseCount { get; set; }
|
public int ExpenseCount { get; set; }
|
||||||
|
|
||||||
public int IncomeCount { get; set; }
|
public int IncomeCount { get; set; }
|
||||||
|
|
||||||
public int TotalCount { get; set; }
|
public int TotalCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,8 +823,11 @@ public class MonthlyStatistics
|
|||||||
public class CategoryStatistics
|
public class CategoryStatistics
|
||||||
{
|
{
|
||||||
public string Classify { get; set; } = string.Empty;
|
public string Classify { get; set; } = string.Empty;
|
||||||
|
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
public int Count { get; set; }
|
public int Count { get; set; }
|
||||||
|
|
||||||
public decimal Percent { get; set; }
|
public decimal Percent { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,8 +837,12 @@ public class CategoryStatistics
|
|||||||
public class TrendStatistics
|
public class TrendStatistics
|
||||||
{
|
{
|
||||||
public int Year { get; set; }
|
public int Year { get; set; }
|
||||||
|
|
||||||
public int Month { get; set; }
|
public int Month { get; set; }
|
||||||
|
|
||||||
public decimal Expense { get; set; }
|
public decimal Expense { get; set; }
|
||||||
|
|
||||||
public decimal Income { get; set; }
|
public decimal Income { get; set; }
|
||||||
|
|
||||||
public decimal Balance { get; set; }
|
public decimal Balance { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace Service.AppSettingModel;
|
namespace Service.AppSettingModel;
|
||||||
|
|
||||||
public class AISettings
|
public class AiSettings
|
||||||
{
|
{
|
||||||
public string Endpoint { get; set; } = string.Empty;
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace Service;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
|
namespace Service;
|
||||||
|
|
||||||
public interface IBudgetService
|
public interface IBudgetService
|
||||||
{
|
{
|
||||||
@@ -24,6 +26,7 @@ public interface IBudgetService
|
|||||||
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
|
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[UsedImplicitly]
|
||||||
public class BudgetService(
|
public class BudgetService(
|
||||||
IBudgetRepository budgetRepository,
|
IBudgetRepository budgetRepository,
|
||||||
IBudgetArchiveRepository budgetArchiveRepository,
|
IBudgetArchiveRepository budgetArchiveRepository,
|
||||||
@@ -79,20 +82,22 @@ public class BudgetService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创造虚拟的存款预算
|
// 创造虚拟的存款预算
|
||||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
dtos.Add(await GetSavingsDtoAsync(
|
||||||
BudgetPeriodType.Month,
|
BudgetPeriodType.Month,
|
||||||
referenceDate,
|
referenceDate,
|
||||||
budgets));
|
budgets));
|
||||||
dtos.Add(await GetVirtualSavingsDtoAsync(
|
dtos.Add(await GetSavingsDtoAsync(
|
||||||
BudgetPeriodType.Year,
|
BudgetPeriodType.Year,
|
||||||
referenceDate,
|
referenceDate,
|
||||||
budgets));
|
budgets));
|
||||||
|
|
||||||
dtos = dtos
|
dtos = dtos
|
||||||
|
.Where(x => x != null)
|
||||||
|
.Cast<BudgetResult>()
|
||||||
.OrderByDescending(x => x.IsMandatoryExpense)
|
.OrderByDescending(x => x.IsMandatoryExpense)
|
||||||
.ThenBy(x => x.Type)
|
.ThenBy(x => x.Type)
|
||||||
.ThenByDescending(x => x.Current)
|
.ThenByDescending(x => x.Current)
|
||||||
.ToList();
|
.ToList()!;
|
||||||
|
|
||||||
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
|
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
|
||||||
}
|
}
|
||||||
@@ -100,7 +105,7 @@ public class BudgetService(
|
|||||||
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
||||||
{
|
{
|
||||||
var referenceDate = new DateTime(year, month, 1);
|
var referenceDate = new DateTime(year, month, 1);
|
||||||
return await GetVirtualSavingsDtoAsync(type, referenceDate);
|
return await GetSavingsDtoAsync(type, referenceDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||||
@@ -128,7 +133,7 @@ public class BudgetService(
|
|||||||
_ => TransactionType.None
|
_ => TransactionType.None
|
||||||
};
|
};
|
||||||
|
|
||||||
if (transactionType == TransactionType.None) return new List<UncoveredCategoryDetail>();
|
if (transactionType == TransactionType.None) return [];
|
||||||
|
|
||||||
// 1. 获取所有预算
|
// 1. 获取所有预算
|
||||||
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
||||||
@@ -205,7 +210,7 @@ public class BudgetService(
|
|||||||
totalLimit += itemLimit;
|
totalLimit += itemLimit;
|
||||||
|
|
||||||
// 当前值累加
|
// 当前值累加
|
||||||
var selectedCategories = budget.SelectedCategories != null ? string.Join(',', budget.SelectedCategories) : string.Empty;
|
var selectedCategories = string.Join(',', budget.SelectedCategories);
|
||||||
var currentAmount = await CalculateCurrentAmountAsync(new()
|
var currentAmount = await CalculateCurrentAmountAsync(new()
|
||||||
{
|
{
|
||||||
Name = budget.Name,
|
Name = budget.Name,
|
||||||
@@ -246,7 +251,7 @@ public class BudgetService(
|
|||||||
|
|
||||||
if (transactionType != TransactionType.None)
|
if (transactionType != TransactionType.None)
|
||||||
{
|
{
|
||||||
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories == null || b.SelectedCategories.Length == 0);
|
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0);
|
||||||
|
|
||||||
var allClassifies = hasGlobalBudget
|
var allClassifies = hasGlobalBudget
|
||||||
? []
|
? []
|
||||||
@@ -256,7 +261,7 @@ public class BudgetService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
DateTime startDate, endDate;
|
DateTime startDate, endDate;
|
||||||
bool groupByMonth = false;
|
bool groupByMonth;
|
||||||
|
|
||||||
if (statType == BudgetPeriodType.Month)
|
if (statType == BudgetPeriodType.Month)
|
||||||
{
|
{
|
||||||
@@ -445,7 +450,7 @@ public class BudgetService(
|
|||||||
.Where(t =>
|
.Where(t =>
|
||||||
{
|
{
|
||||||
var dict = (IDictionary<string, object>)t;
|
var dict = (IDictionary<string, object>)t;
|
||||||
var classify = dict["Classify"]?.ToString() ?? "";
|
var classify = dict["Classify"].ToString() ?? "";
|
||||||
var type = Convert.ToInt32(dict["Type"]);
|
var type = Convert.ToInt32(dict["Type"]);
|
||||||
return type == 0 && !budgetedCategories.Contains(classify);
|
return type == 0 && !budgetedCategories.Contains(classify);
|
||||||
})
|
})
|
||||||
@@ -551,7 +556,8 @@ public class BudgetService(
|
|||||||
// 返回实际消费和硬性消费累加中的较大值
|
// 返回实际消费和硬性消费累加中的较大值
|
||||||
return mandatoryAccumulation;
|
return mandatoryAccumulation;
|
||||||
}
|
}
|
||||||
else if (budget.Type == BudgetPeriodType.Year)
|
|
||||||
|
if (budget.Type == BudgetPeriodType.Year)
|
||||||
{
|
{
|
||||||
// 计算本年的天数(考虑闰年)
|
// 计算本年的天数(考虑闰年)
|
||||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||||
@@ -592,7 +598,7 @@ public class BudgetService(
|
|||||||
return (start, end);
|
return (start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BudgetResult?> GetVirtualSavingsDtoAsync(
|
private async Task<BudgetResult?> GetSavingsDtoAsync(
|
||||||
BudgetPeriodType periodType,
|
BudgetPeriodType periodType,
|
||||||
DateTime? referenceDate = null,
|
DateTime? referenceDate = null,
|
||||||
IEnumerable<BudgetRecord>? existingBudgets = null)
|
IEnumerable<BudgetRecord>? existingBudgets = null)
|
||||||
@@ -657,7 +663,7 @@ public class BudgetService(
|
|||||||
if (b.Category == BudgetCategory.Savings) continue;
|
if (b.Category == BudgetCategory.Savings) continue;
|
||||||
|
|
||||||
processedIds.Add(b.Id);
|
processedIds.Add(b.Id);
|
||||||
decimal factor = 1.0m;
|
decimal factor;
|
||||||
decimal historicalAmount = 0m;
|
decimal historicalAmount = 0m;
|
||||||
var historicalMonths = new List<int>();
|
var historicalMonths = new List<int>();
|
||||||
|
|
||||||
@@ -759,7 +765,6 @@ public class BudgetService(
|
|||||||
|
|
||||||
foreach (var group in deletedBudgets)
|
foreach (var group in deletedBudgets)
|
||||||
{
|
{
|
||||||
var budgetId = group.Key;
|
|
||||||
var months = group.Select(g => g.Key.Month).OrderBy(m => m).ToList();
|
var months = group.Select(g => g.Key.Month).OrderBy(m => m).ToList();
|
||||||
var totalLimit = group.Sum(g => g.Value.HistoricalLimit);
|
var totalLimit = group.Sum(g => g.Value.HistoricalLimit);
|
||||||
var (_, category, name) = group.First().Value;
|
var (_, category, name) = group.First().Value;
|
||||||
@@ -862,12 +867,12 @@ public class BudgetService(
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
foreach (var (Name, Amount) in noLimitIncomeItems)
|
foreach (var (name, amount) in noLimitIncomeItems)
|
||||||
{
|
{
|
||||||
description.Append($"""
|
description.Append($"""
|
||||||
<tr>
|
<tr>
|
||||||
<td>{Name}</td>
|
<td>{name}</td>
|
||||||
<td><span class='income-value'>{Amount:N0}</span></td>
|
<td><span class='income-value'>{amount:N0}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
@@ -953,12 +958,12 @@ public class BudgetService(
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
""");
|
""");
|
||||||
foreach (var (Name, Amount) in noLimitExpenseItems)
|
foreach (var (name, amount) in noLimitExpenseItems)
|
||||||
{
|
{
|
||||||
description.Append($"""
|
description.Append($"""
|
||||||
<tr>
|
<tr>
|
||||||
<td>{Name}</td>
|
<td>{name}</td>
|
||||||
<td><span class='expense-value'>{Amount:N0}</span></td>
|
<td><span class='expense-value'>{amount:N0}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
@@ -1080,13 +1085,13 @@ public record BudgetResult
|
|||||||
public decimal Limit { get; set; }
|
public decimal Limit { get; set; }
|
||||||
public decimal Current { get; set; }
|
public decimal Current { get; set; }
|
||||||
public BudgetCategory Category { get; set; }
|
public BudgetCategory Category { get; set; }
|
||||||
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
public string[] SelectedCategories { get; set; } = [];
|
||||||
public string StartDate { get; set; } = string.Empty;
|
public string StartDate { get; set; } = string.Empty;
|
||||||
public string Period { get; set; } = string.Empty;
|
public string Period { get; set; } = string.Empty;
|
||||||
public DateTime? PeriodStart { get; set; }
|
public DateTime? PeriodStart { get; set; }
|
||||||
public DateTime? PeriodEnd { get; set; }
|
public DateTime? PeriodEnd { get; set; }
|
||||||
public bool NoLimit { get; set; } = false;
|
public bool NoLimit { get; set; }
|
||||||
public bool IsMandatoryExpense { get; set; } = false;
|
public bool IsMandatoryExpense { get; set; }
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
public static BudgetResult FromEntity(
|
public static BudgetResult FromEntity(
|
||||||
@@ -1107,7 +1112,7 @@ public record BudgetResult
|
|||||||
Current = currentAmount,
|
Current = currentAmount,
|
||||||
Category = entity.Category,
|
Category = entity.Category,
|
||||||
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
||||||
? Array.Empty<string>()
|
? []
|
||||||
: entity.SelectedCategories.Split(','),
|
: entity.SelectedCategories.Split(','),
|
||||||
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
||||||
Period = entity.Type switch
|
Period = entity.Type switch
|
||||||
@@ -1157,7 +1162,7 @@ public class BudgetStatsDto
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
|
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<decimal?> Trend { get; set; } = new();
|
public List<decimal?> Trend { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1175,6 +1180,7 @@ public class BudgetCategoryStats
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public BudgetStatsDto Year { get; set; } = new();
|
public BudgetStatsDto Year { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UncoveredCategoryDetail
|
public class UncoveredCategoryDetail
|
||||||
{
|
{
|
||||||
public string Category { get; set; } = string.Empty;
|
public string Category { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ public class ConfigService(IConfigRepository configRepository) : IConfigService
|
|||||||
var config = await configRepository.GetByKeyAsync(key);
|
var config = await configRepository.GetByKeyAsync(key);
|
||||||
var type = typeof(T) switch
|
var type = typeof(T) switch
|
||||||
{
|
{
|
||||||
Type t when t == typeof(bool) => ConfigType.Boolean,
|
{ } t when t == typeof(bool) => ConfigType.Boolean,
|
||||||
Type t when t == typeof(int)
|
{ } t when t == typeof(int)
|
||||||
|| t == typeof(double)
|
|| t == typeof(double)
|
||||||
|| t == typeof(float)
|
|| t == typeof(float)
|
||||||
|| t == typeof(decimal) => ConfigType.Number,
|
|| t == typeof(decimal) => ConfigType.Number,
|
||||||
Type t when t == typeof(string) => ConfigType.String,
|
{ } t when t == typeof(string) => ConfigType.String,
|
||||||
_ => ConfigType.Json
|
_ => ConfigType.Json
|
||||||
};
|
};
|
||||||
var valueStr = type switch
|
var valueStr = type switch
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Service.EmailParseServices;
|
using Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
namespace Service.EmailServices;
|
namespace Service.EmailServices;
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public class EmailHandleService(
|
|||||||
await messageService.AddAsync(
|
await messageService.AddAsync(
|
||||||
"邮件解析失败",
|
"邮件解析失败",
|
||||||
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
$"来自 {from} 发送给 {to} 的邮件(主题:{subject})未能成功解析内容,可能格式已变更或不受支持。",
|
||||||
url: $"/balance?tab=email"
|
url: "/balance?tab=email"
|
||||||
);
|
);
|
||||||
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
logger.LogWarning("未能成功解析邮件内容,跳过账单处理");
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public class EmailParseForm95555(
|
public class EmailParseForm95555(
|
||||||
ILogger<EmailParseForm95555> logger,
|
ILogger<EmailParseForm95555> logger,
|
||||||
@@ -26,7 +26,7 @@ public class EmailParseForm95555(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<(
|
public override Task<(
|
||||||
string card,
|
string card,
|
||||||
string reason,
|
string reason,
|
||||||
decimal amount,
|
decimal amount,
|
||||||
@@ -51,7 +51,7 @@ public class EmailParseForm95555(
|
|||||||
if (matches.Count <= 0)
|
if (matches.Count <= 0)
|
||||||
{
|
{
|
||||||
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
logger.LogWarning("未能从招商银行邮件内容中解析出交易信息");
|
||||||
return [];
|
return Task.FromResult<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]>([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var results = new List<(
|
var results = new List<(
|
||||||
@@ -85,7 +85,7 @@ public class EmailParseForm95555(
|
|||||||
results.Add((card, reason, amount, balance, type, occurredAt));
|
results.Add((card, reason, amount, balance, type, occurredAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results.ToArray();
|
return Task.FromResult(results.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private DateTime? ParseOccurredAt(string value)
|
private DateTime? ParseOccurredAt(string value)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using HtmlAgilityPack;
|
using HtmlAgilityPack;
|
||||||
|
|
||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public class EmailParseFormCCSVC(
|
public class EmailParseFormCcsvc(
|
||||||
ILogger<EmailParseFormCCSVC> logger,
|
ILogger<EmailParseFormCcsvc> logger,
|
||||||
IOpenAiService openAiService
|
IOpenAiService openAiService
|
||||||
) : EmailParseServicesBase(logger, openAiService)
|
) : EmailParseServicesBase(logger, openAiService)
|
||||||
{
|
{
|
||||||
@@ -44,11 +44,6 @@ public class EmailParseFormCCSVC(
|
|||||||
|
|
||||||
// 1. Get Date
|
// 1. Get Date
|
||||||
var dateNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '您的消费明细如下')]");
|
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();
|
var dateText = dateNode.InnerText.Trim();
|
||||||
// "2025/12/21 您的消费明细如下:"
|
// "2025/12/21 您的消费明细如下:"
|
||||||
@@ -56,111 +51,94 @@ public class EmailParseFormCCSVC(
|
|||||||
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
if (!dateMatch.Success || !DateTime.TryParse(dateMatch.Value, out var date))
|
||||||
{
|
{
|
||||||
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
logger.LogWarning("Failed to parse date from: {DateText}", dateText);
|
||||||
return Array.Empty<(string, string, decimal, decimal, TransactionType, DateTime?)>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get Balance (Available Limit)
|
// 2. Get Balance (Available Limit)
|
||||||
decimal balance = 0;
|
decimal balance = 0;
|
||||||
// Find "可用额度" label
|
// Find "可用额度" label
|
||||||
var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]");
|
var limitLabelNode = doc.DocumentNode.SelectSingleNode("//font[contains(text(), '可用额度')]");
|
||||||
if (limitLabelNode != null)
|
|
||||||
{
|
{
|
||||||
// Go up to TR
|
// Go up to TR
|
||||||
var tr = limitLabelNode.Ancestors("tr").FirstOrDefault();
|
var tr = limitLabelNode.Ancestors("tr").FirstOrDefault();
|
||||||
if (tr != null)
|
if (tr != null)
|
||||||
{
|
{
|
||||||
var prevTr = tr.PreviousSibling;
|
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(), '¥')]");
|
||||||
{
|
var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim();
|
||||||
var balanceNode = prevTr.SelectSingleNode(".//font[contains(text(), '¥')]");
|
decimal.TryParse(balanceStr, out balance);
|
||||||
if (balanceNode != null)
|
|
||||||
{
|
|
||||||
var balanceStr = balanceNode.InnerText.Replace("¥", "").Replace(",", "").Trim();
|
|
||||||
decimal.TryParse(balanceStr, out balance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get Transactions
|
// 3. Get Transactions
|
||||||
var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']");
|
var transactionNodes = doc.DocumentNode.SelectNodes("//span[@id='fixBand4']");
|
||||||
if (transactionNodes != null)
|
foreach (var node in transactionNodes)
|
||||||
{
|
{
|
||||||
foreach (var node in transactionNodes)
|
try
|
||||||
{
|
{
|
||||||
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))
|
||||||
{
|
{
|
||||||
// Time
|
occurredAt = dt;
|
||||||
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(" ", "").Trim();
|
|
||||||
if (!decimal.TryParse(amountText, out var amount))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description
|
|
||||||
var descNode = infoNode.SelectSingleNode(".//tr[2]//font");
|
|
||||||
var descText = descNode?.InnerText ?? "";
|
|
||||||
// Replace and non-breaking space (\u00A0) with normal space
|
|
||||||
descText = descText.Replace(" ", " ");
|
|
||||||
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)
|
|
||||||
|
// 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(" ", "").Trim();
|
||||||
|
if (!decimal.TryParse(amountText, out var amount))
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Error parsing transaction node");
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
var descNode = infoNode.SelectSingleNode(".//tr[2]//font");
|
||||||
|
var descText = descNode.InnerText;
|
||||||
|
// Replace and non-breaking space (\u00A0) with normal space
|
||||||
|
descText = descText.Replace(" ", " ");
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Service.EmailParseServices;
|
namespace Service.EmailServices.EmailParse;
|
||||||
|
|
||||||
public interface IEmailParseServices
|
public interface IEmailParseServices
|
||||||
{
|
{
|
||||||
@@ -201,7 +201,7 @@ public abstract class EmailParseServicesBase(
|
|||||||
|
|
||||||
// 收入关键词
|
// 收入关键词
|
||||||
string[] incomeKeywords =
|
string[] incomeKeywords =
|
||||||
{
|
[
|
||||||
"工资", "奖金", "退款",
|
"工资", "奖金", "退款",
|
||||||
"返现", "收入", "转入",
|
"返现", "收入", "转入",
|
||||||
"存入", "利息", "分红",
|
"存入", "利息", "分红",
|
||||||
@@ -233,13 +233,13 @@ public abstract class EmailParseServicesBase(
|
|||||||
// 存取类
|
// 存取类
|
||||||
"现金存入", "柜台存入", "ATM存入",
|
"现金存入", "柜台存入", "ATM存入",
|
||||||
"他人转入", "他人汇入"
|
"他人转入", "他人汇入"
|
||||||
};
|
];
|
||||||
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
return TransactionType.Income;
|
return TransactionType.Income;
|
||||||
|
|
||||||
// 支出关键词
|
// 支出关键词
|
||||||
string[] expenseKeywords =
|
string[] expenseKeywords =
|
||||||
{
|
[
|
||||||
"消费", "支付", "购买",
|
"消费", "支付", "购买",
|
||||||
"转出", "取款", "支出",
|
"转出", "取款", "支出",
|
||||||
"扣款", "缴费", "付款",
|
"扣款", "缴费", "付款",
|
||||||
@@ -269,7 +269,7 @@ public abstract class EmailParseServicesBase(
|
|||||||
// 信用卡/花呗等场景
|
// 信用卡/花呗等场景
|
||||||
"信用卡还款", "花呗还款", "白条还款",
|
"信用卡还款", "花呗还款", "白条还款",
|
||||||
"分期还款", "账单还款", "自动还款"
|
"分期还款", "账单还款", "自动还款"
|
||||||
};
|
];
|
||||||
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
|
||||||
return TransactionType.Expense;
|
return TransactionType.Expense;
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ public class EmailSyncService(
|
|||||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
foreach (var (message, uid) in unreadMessages)
|
foreach (var (message, _) in unreadMessages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ global using System.Globalization;
|
|||||||
global using System.Text;
|
global using System.Text;
|
||||||
global using System.Text.Json;
|
global using System.Text.Json;
|
||||||
global using Entity;
|
global using Entity;
|
||||||
global using FreeSql;
|
|
||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
global using Service.AppSettingModel;
|
global using Service.AppSettingModel;
|
||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
global using System.Text.Json.Nodes;
|
global using System.Text.Json.Nodes;
|
||||||
global using Microsoft.Extensions.Configuration;
|
global using Microsoft.Extensions.Configuration;
|
||||||
global using Common;
|
global using Common;
|
||||||
|
global using System.Net;
|
||||||
|
global using System.Text.Encodings.Web;
|
||||||
@@ -133,7 +133,7 @@ public class ImportService(
|
|||||||
return DateTime.MinValue;
|
return DateTime.MinValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var format in DateTimeFormats)
|
foreach (var format in _dateTimeFormats)
|
||||||
{
|
{
|
||||||
if (DateTime.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
row[key],
|
row[key],
|
||||||
@@ -288,7 +288,7 @@ public class ImportService(
|
|||||||
return DateTime.MinValue;
|
return DateTime.MinValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var format in DateTimeFormats)
|
foreach (var format in _dateTimeFormats)
|
||||||
{
|
{
|
||||||
if (DateTime.TryParseExact(
|
if (DateTime.TryParseExact(
|
||||||
row[key],
|
row[key],
|
||||||
@@ -358,14 +358,13 @@ public class ImportService(
|
|||||||
{
|
{
|
||||||
return await ParseCsvAsync(file);
|
return await ParseCsvAsync(file);
|
||||||
}
|
}
|
||||||
else if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
|
||||||
|
if (fileExtension == ".xlsx" || fileExtension == ".xls")
|
||||||
{
|
{
|
||||||
return await ParseExcelAsync(file);
|
return await ParseExcelAsync(file);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
throw new NotSupportedException("不支持的文件格式");
|
||||||
throw new NotSupportedException("不支持的文件格式");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
private async Task<IDictionary<string, string>[]> ParseCsvAsync(MemoryStream file)
|
||||||
@@ -388,7 +387,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (headers == null || headers.Length == 0)
|
if (headers == null || headers.Length == 0)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = new List<IDictionary<string, string>>();
|
var result = new List<IDictionary<string, string>>();
|
||||||
@@ -420,7 +419,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (worksheet == null || worksheet.Dimension == null)
|
if (worksheet == null || worksheet.Dimension == null)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var rowCount = worksheet.Dimension.End.Row;
|
var rowCount = worksheet.Dimension.End.Row;
|
||||||
@@ -428,7 +427,7 @@ public class ImportService(
|
|||||||
|
|
||||||
if (rowCount < 2)
|
if (rowCount < 2)
|
||||||
{
|
{
|
||||||
return Array.Empty<IDictionary<string, string>>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取表头(第一行)
|
// 读取表头(第一行)
|
||||||
@@ -458,7 +457,7 @@ public class ImportService(
|
|||||||
return await Task.FromResult(result.ToArray());
|
return await Task.FromResult(result.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string[] DateTimeFormats =
|
private static string[] _dateTimeFormats =
|
||||||
[
|
[
|
||||||
"yyyy-MM-dd",
|
"yyyy-MM-dd",
|
||||||
"yyyy-MM-dd HH",
|
"yyyy-MM-dd HH",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ public class EmailSyncJob(
|
|||||||
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync();
|
||||||
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count);
|
||||||
|
|
||||||
foreach (var (message, uid) in unreadMessages)
|
foreach (var (message, _) in unreadMessages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ public class LogCleanupService(ILogger<LogCleanupService> logger) : BackgroundSe
|
|||||||
|
|
||||||
// 尝试解析日期 (格式: yyyyMMdd)
|
// 尝试解析日期 (格式: yyyyMMdd)
|
||||||
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
|
if (DateTime.TryParseExact(dateStr, "yyyyMMdd",
|
||||||
System.Globalization.CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
System.Globalization.DateTimeStyles.None,
|
DateTimeStyles.None,
|
||||||
out var logDate))
|
out var logDate))
|
||||||
{
|
{
|
||||||
if (logDate < cutoffDate)
|
if (logDate < cutoffDate)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using WebPush;
|
using WebPush;
|
||||||
|
using PushSubscription = Entity.PushSubscription;
|
||||||
|
|
||||||
namespace Service;
|
namespace Service;
|
||||||
|
|
||||||
public interface INotificationService
|
public interface INotificationService
|
||||||
{
|
{
|
||||||
Task<string> GetVapidPublicKeyAsync();
|
Task<string> GetVapidPublicKeyAsync();
|
||||||
Task SubscribeAsync(Entity.PushSubscription subscription);
|
Task SubscribeAsync(PushSubscription subscription);
|
||||||
Task SendNotificationAsync(string message, string? url = null);
|
Task SendNotificationAsync(string message, string? url = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ public class NotificationService(
|
|||||||
return Task.FromResult(GetSettings().PublicKey);
|
return Task.FromResult(GetSettings().PublicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubscribeAsync(Entity.PushSubscription subscription)
|
public async Task SubscribeAsync(PushSubscription subscription)
|
||||||
{
|
{
|
||||||
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
@@ -61,7 +62,7 @@ public class NotificationService(
|
|||||||
var webPushClient = new WebPushClient();
|
var webPushClient = new WebPushClient();
|
||||||
|
|
||||||
var subscriptions = await subscriptionRepo.GetAllAsync();
|
var subscriptions = await subscriptionRepo.GetAllAsync();
|
||||||
var payload = System.Text.Json.JsonSerializer.Serialize(new
|
var payload = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
title = "System Notification",
|
title = "System Notification",
|
||||||
body = message,
|
body = message,
|
||||||
@@ -78,7 +79,7 @@ public class NotificationService(
|
|||||||
}
|
}
|
||||||
catch (WebPushException ex)
|
catch (WebPushException ex)
|
||||||
{
|
{
|
||||||
if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
if (ex.StatusCode == HttpStatusCode.Gone || ex.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
await subscriptionRepo.DeleteAsync(sub.Id);
|
await subscriptionRepo.DeleteAsync(sub.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public interface IOpenAiService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public class OpenAiService(
|
public class OpenAiService(
|
||||||
IOptions<AISettings> aiSettings,
|
IOptions<AiSettings> aiSettings,
|
||||||
ILogger<OpenAiService> logger
|
ILogger<OpenAiService> logger
|
||||||
) : IOpenAiService
|
) : IOpenAiService
|
||||||
{
|
{
|
||||||
@@ -158,10 +158,8 @@ public class OpenAiService(
|
|||||||
var json = JsonSerializer.Serialize(payload);
|
var json = JsonSerializer.Serialize(payload);
|
||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
{
|
request.Content = content;
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
@@ -232,10 +230,8 @@ public class OpenAiService(
|
|||||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
// 使用 SendAsync 来支持 HttpCompletionOption
|
// 使用 SendAsync 来支持 HttpCompletionOption
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
{
|
request.Content = content;
|
||||||
Content = content
|
|
||||||
};
|
|
||||||
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="JetBrains.Annotations" />
|
||||||
<PackageReference Include="MailKit" />
|
<PackageReference Include="MailKit" />
|
||||||
<PackageReference Include="MimeKit" />
|
<PackageReference Include="MimeKit" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public class SmartHandleService(
|
|||||||
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
|
||||||
|
|
||||||
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
|
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
|
||||||
var sendedIds = new HashSet<long>();
|
var sentIds = new HashSet<long>();
|
||||||
|
|
||||||
// 将流解析逻辑提取为本地函数以减少嵌套
|
// 将流解析逻辑提取为本地函数以减少嵌套
|
||||||
void HandleResult(GroupClassifyResult? result)
|
void HandleResult(GroupClassifyResult? result)
|
||||||
@@ -154,16 +154,18 @@ public class SmartHandleService(
|
|||||||
if (group == null) return;
|
if (group == null) return;
|
||||||
foreach (var id in group.Ids)
|
foreach (var id in group.Ids)
|
||||||
{
|
{
|
||||||
if (sendedIds.Add(id))
|
if (!sentIds.Add(id))
|
||||||
{
|
{
|
||||||
var resultJson = JsonSerializer.Serialize(new
|
continue;
|
||||||
{
|
|
||||||
id,
|
|
||||||
result.Classify,
|
|
||||||
result.Type
|
|
||||||
});
|
|
||||||
chunkAction(("data", resultJson));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var resultJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
result.Classify,
|
||||||
|
result.Type
|
||||||
|
});
|
||||||
|
chunkAction(("data", resultJson));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ public class SmartHandleService(
|
|||||||
}
|
}
|
||||||
catch (Exception exArr)
|
catch (Exception exArr)
|
||||||
{
|
{
|
||||||
logger.LogDebug(exArr, "按数组解析AI返回失败,回退到逐对象解析。预览: {Preview}", arrJson?.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
|
logger.LogDebug(exArr, "按数组解析AI返回失败,回退到逐对象解析。预览: {Preview}", arrJson.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +338,7 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
content = $"""
|
content = $"""
|
||||||
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
|
||||||
{System.Net.WebUtility.HtmlEncode(sqlText)}
|
{WebUtility.HtmlEncode(sqlText)}
|
||||||
</pre>
|
</pre>
|
||||||
"""
|
"""
|
||||||
})
|
})
|
||||||
@@ -361,7 +363,7 @@ public class SmartHandleService(
|
|||||||
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
|
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
});
|
});
|
||||||
|
|
||||||
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
|
||||||
@@ -429,7 +431,6 @@ public class SmartHandleService(
|
|||||||
{
|
{
|
||||||
// 获取所有分类
|
// 获取所有分类
|
||||||
var categories = await categoryRepository.GetAllAsync();
|
var categories = await categoryRepository.GetAllAsync();
|
||||||
var categoryList = string.Join("、", categories.Select(c => $"{GetTypeName(c.Type)}-{c.Name}"));
|
|
||||||
|
|
||||||
// 构建分类信息
|
// 构建分类信息
|
||||||
var categoryInfo = new StringBuilder();
|
var categoryInfo = new StringBuilder();
|
||||||
@@ -542,13 +543,13 @@ public class SmartHandleService(
|
|||||||
public record GroupClassifyResult
|
public record GroupClassifyResult
|
||||||
{
|
{
|
||||||
[JsonPropertyName("reason")]
|
[JsonPropertyName("reason")]
|
||||||
public string Reason { get; set; } = string.Empty;
|
public string Reason { get; init; } = string.Empty;
|
||||||
|
|
||||||
[JsonPropertyName("classify")]
|
[JsonPropertyName("classify")]
|
||||||
public string? Classify { get; set; }
|
public string? Classify { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public TransactionType Type { get; set; }
|
public TransactionType Type { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
namespace Service;
|
using JiebaNet.Analyser;
|
||||||
|
|
||||||
using JiebaNet.Segmenter;
|
using JiebaNet.Segmenter;
|
||||||
using JiebaNet.Analyser;
|
|
||||||
using Microsoft.Extensions.Logging;
|
namespace Service;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 文本分词服务接口
|
/// 文本分词服务接口
|
||||||
@@ -78,7 +77,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
return new List<string>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -119,7 +118,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
|
_logger.LogError(ex, "提取关键词失败,文本: {Text}", text);
|
||||||
// 降级处理:返回原文
|
// 降级处理:返回原文
|
||||||
return new List<string> { text.Length > 10 ? text.Substring(0, 10) : text };
|
return [text.Length > 10 ? text.Substring(0, 10) : text];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
return new List<string>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -146,7 +145,7 @@ public class TextSegmentService : ITextSegmentService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "分词失败,文本: {Text}", text);
|
_logger.LogError(ex, "分词失败,文本: {Text}", text);
|
||||||
return new List<string> { text };
|
return [text];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ public class TransactionPeriodicService(
|
|||||||
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 0 && d <= 6)
|
.Where(d => d is >= 0 and <= 6)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return executeDays.Contains(dayOfWeek);
|
return executeDays.Contains(dayOfWeek);
|
||||||
@@ -160,7 +160,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 1 && d <= 31)
|
.Where(d => d is >= 1 and <= 31)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
||||||
@@ -223,7 +223,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 0 && d <= 6)
|
.Where(d => d is >= 0 and <= 6)
|
||||||
.OrderBy(d => d)
|
.OrderBy(d => d)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ public class TransactionPeriodicService(
|
|||||||
|
|
||||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||||
.Where(d => d >= 1 && d <= 31)
|
.Where(d => d is >= 1 and <= 31)
|
||||||
.OrderBy(d => d)
|
.OrderBy(d => d)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
10
WebApi.Test/UnitTest1.cs
Normal file
10
WebApi.Test/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace WebApi.Test;
|
||||||
|
|
||||||
|
public class UnitTest1
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
25
WebApi.Test/WebApi.Test.csproj
Normal file
25
WebApi.Test/WebApi.Test.csproj
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||||
|
<PackageReference Include="xunit"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Service\Service.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -50,9 +50,6 @@ public class BillImportController(
|
|||||||
return "文件大小不能超过 10MB".Fail();
|
return "文件大小不能超过 10MB".Fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名
|
|
||||||
var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{fileExtension}";
|
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
var ok = false;
|
var ok = false;
|
||||||
var message = string.Empty;
|
var message = string.Empty;
|
||||||
@@ -69,6 +66,11 @@ public class BillImportController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return message.Fail();
|
||||||
|
}
|
||||||
|
|
||||||
return message.Ok();
|
return message.Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -77,4 +79,4 @@ public class BillImportController(
|
|||||||
return $"文件上传失败: {ex.Message}".Fail();
|
return $"文件上传失败: {ex.Message}".Fail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ public class BudgetController(
|
|||||||
Type = dto.Type,
|
Type = dto.Type,
|
||||||
Limit = limit,
|
Limit = limit,
|
||||||
Category = dto.Category,
|
Category = dto.Category,
|
||||||
SelectedCategories = dto.SelectedCategories != null ? string.Join(",", dto.SelectedCategories) : string.Empty,
|
SelectedCategories = string.Join(",", dto.SelectedCategories),
|
||||||
StartDate = dto.StartDate ?? DateTime.Now,
|
StartDate = dto.StartDate ?? DateTime.Now,
|
||||||
NoLimit = dto.NoLimit,
|
NoLimit = dto.NoLimit,
|
||||||
IsMandatoryExpense = dto.IsMandatoryExpense
|
IsMandatoryExpense = dto.IsMandatoryExpense
|
||||||
@@ -182,7 +182,7 @@ public class BudgetController(
|
|||||||
budget.Type = dto.Type;
|
budget.Type = dto.Type;
|
||||||
budget.Limit = limit;
|
budget.Limit = limit;
|
||||||
budget.Category = dto.Category;
|
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.NoLimit = dto.NoLimit;
|
||||||
budget.IsMandatoryExpense = dto.IsMandatoryExpense;
|
budget.IsMandatoryExpense = dto.IsMandatoryExpense;
|
||||||
if (dto.StartDate.HasValue)
|
if (dto.StartDate.HasValue)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ public class CreateBudgetDto
|
|||||||
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
|
public BudgetPeriodType Type { get; set; } = BudgetPeriodType.Month;
|
||||||
public decimal Limit { get; set; }
|
public decimal Limit { get; set; }
|
||||||
public BudgetCategory Category { get; set; }
|
public BudgetCategory Category { get; set; }
|
||||||
public string[] SelectedCategories { get; set; } = Array.Empty<string>();
|
public string[] SelectedCategories { get; set; } = [];
|
||||||
public DateTime? StartDate { get; set; }
|
public DateTime? StartDate { get; set; }
|
||||||
public bool NoLimit { get; set; } = false;
|
public bool NoLimit { get; set; } = false;
|
||||||
public bool IsMandatoryExpense { get; set; } = false;
|
public bool IsMandatoryExpense { get; set; } = false;
|
||||||
|
|||||||
@@ -6,25 +6,32 @@
|
|||||||
public class EmailMessageDto
|
public class EmailMessageDto
|
||||||
{
|
{
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
|
|
||||||
public string Subject { get; set; } = string.Empty;
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string From { get; set; } = string.Empty;
|
public string From { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Body { get; set; } = string.Empty;
|
public string Body { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string HtmlBody { get; set; } = string.Empty;
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
|
||||||
public DateTime ReceivedDate { get; set; }
|
public DateTime ReceivedDate { get; set; }
|
||||||
|
|
||||||
public DateTime CreateTime { get; set; }
|
public DateTime CreateTime { get; set; }
|
||||||
|
|
||||||
public DateTime? UpdateTime { get; set; }
|
public DateTime? UpdateTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 已解析的账单数量
|
/// 已解析的账单数量
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int TransactionCount { get; set; }
|
public int TransactionCount { get; set; }
|
||||||
|
|
||||||
public string ToName { get; set; } = string.Empty;
|
public string ToName { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从实体转换为DTO
|
/// 从实体转换为DTO
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static EmailMessageDto FromEntity(Entity.EmailMessage entity, int transactionCount = 0)
|
public static EmailMessageDto FromEntity(EmailMessage entity, int transactionCount = 0)
|
||||||
{
|
{
|
||||||
return new EmailMessageDto
|
return new EmailMessageDto
|
||||||
{
|
{
|
||||||
@@ -37,7 +44,7 @@ public class EmailMessageDto
|
|||||||
CreateTime = entity.CreateTime,
|
CreateTime = entity.CreateTime,
|
||||||
UpdateTime = entity.UpdateTime,
|
UpdateTime = entity.UpdateTime,
|
||||||
TransactionCount = transactionCount,
|
TransactionCount = transactionCount,
|
||||||
ToName = entity.To?.Split('<').FirstOrDefault()?.Trim() ?? "未知"
|
ToName = entity.To.Split('<').FirstOrDefault()?.Trim() ?? "未知"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
public class PagedResponse<T> : BaseResponse<T[]>
|
public class PagedResponse<T> : BaseResponse<T[]>
|
||||||
{
|
{
|
||||||
public long LastId { get; set; } = 0;
|
public long LastId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 最后一条记录的时间(用于游标分页)
|
/// 最后一条记录的时间(用于游标分页)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using Service.EmailServices;
|
using Service.EmailServices;
|
||||||
|
|
||||||
namespace WebApi.Controllers.EmailMessage;
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
@@ -86,10 +86,8 @@ public class EmailMessageController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "删除邮件失败,邮件不存在".Fail();
|
||||||
return "删除邮件失败,邮件不存在".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -117,10 +115,8 @@ public class EmailMessageController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "重新分析失败".Fail();
|
||||||
return "重新分析失败".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Quartz.Impl.Matchers;
|
||||||
|
|
||||||
namespace WebApi.Controllers;
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ public class JobController(ISchedulerFactory schedulerFactory, ILogger<JobContro
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var scheduler = await schedulerFactory.GetScheduler();
|
var scheduler = await schedulerFactory.GetScheduler();
|
||||||
var jobKeys = await scheduler.GetJobKeys(Quartz.Impl.Matchers.GroupMatcher<JobKey>.AnyGroup());
|
var jobKeys = await scheduler.GetJobKeys(GroupMatcher<JobKey>.AnyGroup());
|
||||||
var jobStatuses = new List<JobStatus>();
|
var jobStatuses = new List<JobStatus>();
|
||||||
|
|
||||||
foreach (var jobKey in jobKeys)
|
foreach (var jobKey in jobKeys)
|
||||||
@@ -101,9 +102,13 @@ public class JobController(ISchedulerFactory schedulerFactory, ILogger<JobContro
|
|||||||
public class JobStatus
|
public class JobStatus
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string JobDescription { get; set; } = string.Empty;
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string TriggerDescription { get; set; } = string.Empty;
|
public string TriggerDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Status { get; set; } = string.Empty;
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string NextRunTime { get; set; } = string.Empty;
|
public string NextRunTime { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace WebApi.Controllers;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
@@ -102,7 +104,7 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
|
|||||||
var currentLog = new StringBuilder();
|
var currentLog = new StringBuilder();
|
||||||
|
|
||||||
// 日志行开始的正则表达式
|
// 日志行开始的正则表达式
|
||||||
var logStartPattern = new System.Text.RegularExpressions.Regex(
|
var logStartPattern = new Regex(
|
||||||
@"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}\]"
|
@"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2}\]"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -151,10 +153,10 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
|
|||||||
// 日志格式示例: [2025-12-29 16:38:45.730 +08:00] [INF] Message here
|
// 日志格式示例: [2025-12-29 16:38:45.730 +08:00] [INF] Message here
|
||||||
// 使用正则表达式解析
|
// 使用正则表达式解析
|
||||||
// 使用 Singleline 模式使 '.' 可以匹配换行,这样 multi-line 消息可以被完整捕获。
|
// 使用 Singleline 模式使 '.' 可以匹配换行,这样 multi-line 消息可以被完整捕获。
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(
|
var match = Regex.Match(
|
||||||
line,
|
line,
|
||||||
@"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] ([\s\S]*)$",
|
@"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] ([\s\S]*)$",
|
||||||
System.Text.RegularExpressions.RegexOptions.Singleline
|
RegexOptions.Singleline
|
||||||
);
|
);
|
||||||
|
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
public class NotificationController(INotificationService notificationService) : ControllerBase
|
public class NotificationController(INotificationService notificationService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet()]
|
[HttpGet]
|
||||||
public async Task<BaseResponse<string>> GetVapidPublicKey()
|
public async Task<BaseResponse<string>> GetVapidPublicKey()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -85,10 +85,8 @@ public class TransactionCategoryController(
|
|||||||
{
|
{
|
||||||
return category.Id.Ok();
|
return category.Id.Ok();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "创建分类失败".Fail<long>();
|
||||||
return "创建分类失败".Fail<long>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -133,10 +131,8 @@ public class TransactionCategoryController(
|
|||||||
{
|
{
|
||||||
return "更新分类成功".Ok();
|
return "更新分类成功".Ok();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "更新分类失败".Fail();
|
||||||
return "更新分类失败".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -165,10 +161,8 @@ public class TransactionCategoryController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "删除分类失败,分类不存在".Fail();
|
||||||
return "删除分类失败,分类不存在".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -196,10 +190,8 @@ public class TransactionCategoryController(
|
|||||||
{
|
{
|
||||||
return categories.Count.Ok();
|
return categories.Count.Ok();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "批量创建分类失败".Fail<int>();
|
||||||
return "批量创建分类失败".Fail<int>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
namespace WebApi.Controllers;
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
using Repository;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 周期性账单控制器
|
/// 周期性账单控制器
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
namespace WebApi.Controllers;
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using Repository;
|
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
@@ -183,10 +179,8 @@ public class TransactionRecordController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "创建交易记录失败".Fail();
|
||||||
return "创建交易记录失败".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -225,10 +219,8 @@ public class TransactionRecordController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "更新交易记录失败".Fail();
|
||||||
return "更新交易记录失败".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -250,10 +242,8 @@ public class TransactionRecordController(
|
|||||||
{
|
{
|
||||||
return BaseResponse.Done();
|
return BaseResponse.Done();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
return "删除交易记录失败,记录不存在".Fail();
|
||||||
return "删除交易记录失败,记录不存在".Fail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -278,9 +268,9 @@ public class TransactionRecordController(
|
|||||||
|
|
||||||
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month, savingClassify);
|
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month, savingClassify);
|
||||||
var result = statistics.Select(s => new DailyStatisticsDto(
|
var result = statistics.Select(s => new DailyStatisticsDto(
|
||||||
s.Key,
|
s.Key,
|
||||||
s.Value.count,
|
s.Value.count,
|
||||||
s.Value.expense,
|
s.Value.expense,
|
||||||
s.Value.income,
|
s.Value.income,
|
||||||
s.Value.saving
|
s.Value.saving
|
||||||
)).ToList();
|
)).ToList();
|
||||||
@@ -313,13 +303,13 @@ public class TransactionRecordController(
|
|||||||
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
|
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
|
||||||
|
|
||||||
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(
|
var statistics = await transactionRepository.GetDailyStatisticsByRangeAsync(
|
||||||
effectiveStartDate,
|
effectiveStartDate,
|
||||||
effectiveEndDate,
|
effectiveEndDate,
|
||||||
savingClassify);
|
savingClassify);
|
||||||
var result = statistics.Select(s => new DailyStatisticsDto(
|
var result = statistics.Select(s => new DailyStatisticsDto(
|
||||||
s.Key,
|
s.Key,
|
||||||
s.Value.count,
|
s.Value.count,
|
||||||
s.Value.expense,
|
s.Value.expense,
|
||||||
s.Value.income,
|
s.Value.income,
|
||||||
s.Value.income - s.Value.expense
|
s.Value.income - s.Value.expense
|
||||||
)).ToList();
|
)).ToList();
|
||||||
@@ -393,7 +383,8 @@ public class TransactionRecordController(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>>();
|
return $"获取趋势统计数据失败: {ex.Message}".Fail<List<TrendStatistics>>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,9 +404,16 @@ public class TransactionRecordController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartHandleService.AnalyzeBillAsync(request.UserInput, async (chunk) =>
|
await smartHandleService.AnalyzeBillAsync(request.UserInput, async void (chunk) =>
|
||||||
{
|
{
|
||||||
await WriteEventAsync(chunk);
|
try
|
||||||
|
{
|
||||||
|
await WriteEventAsync(chunk);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "流式写入账单分析结果失败");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,11 +498,18 @@ public class TransactionRecordController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartHandleService.SmartClassifyAsync(request.TransactionIds.ToArray(), async (chunk) =>
|
await smartHandleService.SmartClassifyAsync(request.TransactionIds.ToArray(), async void (chunk) =>
|
||||||
{
|
{
|
||||||
var (eventType, content) = chunk;
|
try
|
||||||
await TrySetUnconfirmedAsync(eventType, content);
|
{
|
||||||
await WriteEventAsync(eventType, content);
|
var (eventType, content) = chunk;
|
||||||
|
await TrySetUnconfirmedAsync(eventType, content);
|
||||||
|
await WriteEventAsync(eventType, content);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "流式写入智能分类结果失败");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Response.Body.FlushAsync();
|
await Response.Body.FlushAsync();
|
||||||
@@ -524,7 +529,7 @@ public class TransactionRecordController(
|
|||||||
var classify = jsonObject?["Classify"]?.GetValue<string>() ?? string.Empty;
|
var classify = jsonObject?["Classify"]?.GetValue<string>() ?? string.Empty;
|
||||||
var typeValue = jsonObject?["Type"]?.GetValue<int>() ?? -1;
|
var typeValue = jsonObject?["Type"]?.GetValue<int>() ?? -1;
|
||||||
|
|
||||||
if(id == 0 || typeValue == -1 || string.IsNullOrEmpty(classify))
|
if (id == 0 || typeValue == -1 || string.IsNullOrEmpty(classify))
|
||||||
{
|
{
|
||||||
logger.LogWarning("解析智能分类结果时,发现无效数据,内容: {Content}", content);
|
logger.LogWarning("解析智能分类结果时,发现无效数据,内容: {Content}", content);
|
||||||
return;
|
return;
|
||||||
@@ -550,7 +555,6 @@ public class TransactionRecordController(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "解析智能分类结果失败,内容: {Content}", content);
|
logger.LogError(ex, "解析智能分类结果失败,内容: {Content}", content);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,14 +580,17 @@ public class TransactionRecordController(
|
|||||||
{
|
{
|
||||||
record.Type = item.Type.Value;
|
record.Type = item.Type.Value;
|
||||||
}
|
}
|
||||||
if(!string.IsNullOrEmpty(record.Classify))
|
|
||||||
|
if (!string.IsNullOrEmpty(record.Classify))
|
||||||
{
|
{
|
||||||
record.UnconfirmedClassify = null;
|
record.UnconfirmedClassify = null;
|
||||||
}
|
}
|
||||||
if(record.Type == item.Type)
|
|
||||||
|
if (record.Type == item.Type)
|
||||||
{
|
{
|
||||||
record.UnconfirmedType = TransactionType.None;
|
record.UnconfirmedType = TransactionType.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
var success = await transactionRepository.UpdateAsync(record);
|
var success = await transactionRepository.UpdateAsync(record);
|
||||||
if (success)
|
if (success)
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -811,7 +818,6 @@ public record OffsetTransactionDto(
|
|||||||
long Id2
|
long Id2
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
public record ParseOneLineRequestDto(
|
public record ParseOneLineRequestDto(
|
||||||
string Text
|
string Text
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Quartz;
|
using Quartz;
|
||||||
|
using Service.Jobs;
|
||||||
|
|
||||||
namespace WebApi;
|
namespace WebApi;
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ public static class Expand
|
|||||||
|
|
||||||
// 配置邮件同步任务 - 每10分钟执行一次
|
// 配置邮件同步任务 - 每10分钟执行一次
|
||||||
var emailJobKey = new JobKey("EmailSyncJob");
|
var emailJobKey = new JobKey("EmailSyncJob");
|
||||||
q.AddJob<Service.Jobs.EmailSyncJob>(opts => opts
|
q.AddJob<EmailSyncJob>(opts => opts
|
||||||
.WithIdentity(emailJobKey)
|
.WithIdentity(emailJobKey)
|
||||||
.WithDescription("邮件同步任务"));
|
.WithDescription("邮件同步任务"));
|
||||||
q.AddTrigger(opts => opts
|
q.AddTrigger(opts => opts
|
||||||
@@ -24,7 +25,7 @@ public static class Expand
|
|||||||
|
|
||||||
// 配置周期性账单任务 - 每天早上6点执行
|
// 配置周期性账单任务 - 每天早上6点执行
|
||||||
var periodicBillJobKey = new JobKey("PeriodicBillJob");
|
var periodicBillJobKey = new JobKey("PeriodicBillJob");
|
||||||
q.AddJob<Service.Jobs.PeriodicBillJob>(opts => opts
|
q.AddJob<PeriodicBillJob>(opts => opts
|
||||||
.WithIdentity(periodicBillJobKey)
|
.WithIdentity(periodicBillJobKey)
|
||||||
.WithDescription("周期性账单任务"));
|
.WithDescription("周期性账单任务"));
|
||||||
q.AddTrigger(opts => opts
|
q.AddTrigger(opts => opts
|
||||||
@@ -35,7 +36,7 @@ public static class Expand
|
|||||||
|
|
||||||
// 配置预算归档任务 - 每个月1号晚11点执行
|
// 配置预算归档任务 - 每个月1号晚11点执行
|
||||||
var budgetArchiveJobKey = new JobKey("BudgetArchiveJob");
|
var budgetArchiveJobKey = new JobKey("BudgetArchiveJob");
|
||||||
q.AddJob<Service.Jobs.BudgetArchiveJob>(opts => opts
|
q.AddJob<BudgetArchiveJob>(opts => opts
|
||||||
.WithIdentity(budgetArchiveJobKey)
|
.WithIdentity(budgetArchiveJobKey)
|
||||||
.WithDescription("预算归档任务"));
|
.WithDescription("预算归档任务"));
|
||||||
q.AddTrigger(opts => opts
|
q.AddTrigger(opts => opts
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ global using WebApi.Controllers.Dto;
|
|||||||
global using Repository;
|
global using Repository;
|
||||||
global using Entity;
|
global using Entity;
|
||||||
global using System.Text;
|
global using System.Text;
|
||||||
|
global using System.Text.Json;
|
||||||
|
global using System.Text.Json.Nodes;
|
||||||
|
|||||||
@@ -22,21 +22,21 @@ builder.Host.UseSerilog((context, loggerConfig) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddControllers(options =>
|
builder.Services.AddControllers(mvcOptions =>
|
||||||
{
|
{
|
||||||
var policy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
|
var policy = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
options.Filters.Add(new AuthorizeFilter(policy));
|
mvcOptions.Filters.Add(new AuthorizeFilter(policy));
|
||||||
});
|
});
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// 配置 CORS
|
// 配置 CORS
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(corsOptions =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
corsOptions.AddDefaultPolicy(policy =>
|
||||||
{
|
{
|
||||||
policy.WithOrigins("http://localhost:5173")
|
policy.WithOrigins("http://localhost:5173")
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
@@ -46,7 +46,7 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
// 绑定配置
|
// 绑定配置
|
||||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||||
builder.Services.Configure<AISettings>(builder.Configuration.GetSection("OpenAI"));
|
builder.Services.Configure<AiSettings>(builder.Configuration.GetSection("OpenAI"));
|
||||||
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
|
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
|
||||||
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
builder.Services.Configure<AuthSettings>(builder.Configuration.GetSection("AuthSettings"));
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ var jwtSettings = builder.Configuration.GetSection("JwtSettings");
|
|||||||
var secretKey = jwtSettings["SecretKey"]!;
|
var secretKey = jwtSettings["SecretKey"]!;
|
||||||
var key = Encoding.UTF8.GetBytes(secretKey);
|
var key = Encoding.UTF8.GetBytes(secretKey);
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(authenticationOptions =>
|
||||||
{
|
{
|
||||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
authenticationOptions.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
authenticationOptions.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
})
|
})
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(jwtBearerOptions =>
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
@@ -73,7 +73,7 @@ builder.Services.AddAuthentication(options =>
|
|||||||
IssuerSigningKey = new SymmetricSecurityKey(key),
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||||
ClockSkew = TimeSpan.Zero
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
options.Events = new JwtBearerEvents
|
jwtBearerOptions.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnChallenge = async context =>
|
OnChallenge = async context =>
|
||||||
{
|
{
|
||||||
|
|||||||
46
qodana.yaml
Normal file
46
qodana.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user