功能添加
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s

This commit is contained in:
孙诚
2025-12-26 15:21:31 +08:00
parent 7dfb6a5902
commit cb11d80d1f
26 changed files with 2208 additions and 841 deletions

View File

@@ -110,7 +110,6 @@ public class TransactionRecordController(
Amount = dto.Amount,
Type = dto.Type,
Classify = dto.Classify ?? string.Empty,
SubClassify = dto.SubClassify ?? string.Empty,
ImportFrom = "手动录入",
EmailMessageId = 0 // 手动录入的记录EmailMessageId 设为 0
};
@@ -155,7 +154,6 @@ public class TransactionRecordController(
transaction.Balance = dto.Balance;
transaction.Type = dto.Type;
transaction.Classify = dto.Classify ?? string.Empty;
transaction.SubClassify = dto.SubClassify ?? string.Empty;
var success = await transactionRepository.UpdateAsync(transaction);
if (success)
@@ -322,46 +320,65 @@ public class TransactionRecordController(
try
{
// 获取要分类的账单
var records = await transactionRepository.GetUnclassifiedAsync(request.PageSize);
// 验证账单ID列表
if (request.TransactionIds == null || request.TransactionIds.Count == 0)
{
await WriteEventAsync("error", "请提供要分类的账单ID");
return;
}
// 获取指定ID的账单
var records = new List<Entity.TransactionRecord>();
foreach (var id in request.TransactionIds)
{
var record = await transactionRepository.GetByIdAsync(id);
if (record != null && record.Classify == string.Empty)
{
records.Add(record);
}
}
if (records.Count == 0)
{
await WriteEventAsync("error", "没有需要分类的账单");
await WriteEventAsync("error", "找不到指定的账单");
return;
}
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
// 构建分类信息
var categoryInfo = string.Join("\n", categories
.Select(c =>
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
var children = categories.Where(x => x.ParentId == c.Id).ToList();
var childrenStr = children.Count > 0
? $",子分类:{string.Join("", children.Select(x => x.Name))}"
: "";
return $"- {c.Name} ({(c.Type == TransactionType.Expense ? "" : "")}){childrenStr}";
}));
categoryInfo.AppendLine($"- {category.Name}");
}
}
// 构建账单信息
var billsInfo = string.Join("\n", records.Select((r, i) =>
var billsInfo = string.Join("\n", records.Select((r, i) =>
$"{i + 1}. ID={r.Id}, 摘要={r.Reason}, 金额={r.Amount}, 类型={GetTypeName(r.Type)}"));
var systemPrompt = $@"你是一个专业的账单分类助手。请根据提供的账单信息和分类列表,为每个账单选择最合适的分类。
var systemPrompt = $$"""
你是一个专业的账单分类助手。请根据提供的账单信息和分类列表,为每个账单选择最合适的分类。
可用的分类列表:
{categoryInfo}
可用的分类列表:
{{categoryInfo}}
分类规则:
1. 根据账单的摘要和金额,选择最匹配的一级分类
2. 如果有合适的子分类,也要指定子分类
3. 如果无法确定分类,可以选择""其他""
分类规则:
1. 根据账单的摘要和金额,选择最匹配的分类
2. 如果无法确定分类,可以选择""其他""
请对每个账单进行分类,每次输出一个账单的分类结果,格式如下:
{{""id"": 账单ID, ""classify"": ""一级分类"", ""subClassify"": ""子分类""}}
请对每个账单进行分类,每次输出一个账单的分类结果,格式如下:
{"id": 账单ID, "type": 0:支出/1:收入/2:不计入收支(Type为Number枚举值) ,"classify": 分类名称}
只输出JSON不要有其他文字说明。";
JSON
""";
var userPrompt = $@"请为以下账单进行分类:
@@ -403,7 +420,11 @@ public class TransactionRecordController(
if (record != null)
{
record.Classify = item.Classify ?? string.Empty;
record.SubClassify = item.SubClassify ?? string.Empty;
// 如果提供了Type也更新Type
if (item.Type.HasValue)
{
record.Type = item.Type.Value;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
successCount++;
@@ -429,6 +450,183 @@ public class TransactionRecordController(
}
}
/// <summary>
/// 获取按交易摘要分组的统计信息(支持分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<Repository.ReasonGroupDto>> GetReasonGroupsAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 20)
{
try
{
var (list, total) = await transactionRepository.GetReasonGroupsAsync(pageIndex, pageSize);
return new PagedResponse<Repository.ReasonGroupDto>
{
Success = true,
Data = list.ToArray(),
Total = total
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易摘要分组失败");
return PagedResponse<Repository.ReasonGroupDto>.Fail($"获取交易摘要分组失败: {ex.Message}");
}
}
/// <summary>
/// 按摘要批量更新分类
/// </summary>
[HttpPost]
public async Task<BaseResponse<int>> BatchUpdateByReasonAsync([FromBody] BatchUpdateByReasonDto dto)
{
try
{
var count = await transactionRepository.BatchUpdateByReasonAsync(dto.Reason, dto.Type, dto.Classify);
return new BaseResponse<int>
{
Success = true,
Data = count,
Message = $"成功更新 {count} 条记录"
};
}
catch (Exception ex)
{
logger.LogError(ex, "按摘要批量更新分类失败,摘要: {Reason}", dto.Reason);
return BaseResponse<int>.Fail($"按摘要批量更新分类失败: {ex.Message}");
}
}
/// <summary>
/// 自然语言分析 - 根据用户输入的自然语言查询交易记录并预设分类
/// </summary>
[HttpPost]
public async Task<BaseResponse<NlpAnalysisResult>> NlpAnalysisAsync([FromBody] NlpAnalysisRequest request)
{
try
{
if (string.IsNullOrWhiteSpace(request.UserInput))
{
return BaseResponse<NlpAnalysisResult>.Fail("请输入查询条件");
}
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
var systemPrompt = $$"""
你是一个专业的交易记录查询助手。用户会用自然语言描述他想要查询和分类的交易记录。
可用的分类列表:
{{categoryInfo}}
你需要分析用户的需求,提取以下信息:
1. 查询SQL, 根据用户的描述生成SQL Where 子句用于查询交易记录。例如Reason LIKE '%关键词%'
Table Schema:
TransactionRecord (
Id LONG,
Reason STRING,
Amount DECIMAL,
RefundAmount DECIMAL,
Balance DECIMAL,
OccurredAt DATETIME,
EmailMessageId LONG,
Type INT,
Classify STRING,
ImportNo STRING,
ImportFrom STRING
)
2. 目标交易类型0:支出, 1:收入, 2:不计入收支)
3. 目标分类名称(必须从上面的分类列表中选择)
请以JSON格式输出格式如下
{
"sql": "SQL",
"targetType": ,
"targetClassify": "分类名称"
}
JSON
""";
var userPrompt = $"用户输入:{request.UserInput}";
// 调用AI分析
var aiResponse = await openAiService.ChatAsync(systemPrompt, userPrompt);
logger.LogInformation("NLP分析AI返回结果: {Response}", aiResponse);
if (string.IsNullOrWhiteSpace(aiResponse))
{
return BaseResponse<NlpAnalysisResult>.Fail("AI分析失败请检查AI配置");
}
// 解析AI返回的JSON
NlpAnalysisInfo? analysisInfo;
try
{
analysisInfo = JsonSerializer.Deserialize<NlpAnalysisInfo>(aiResponse);
if (analysisInfo == null)
{
return BaseResponse<NlpAnalysisResult>.Fail("AI返回格式错误");
}
}
catch (Exception ex)
{
logger.LogError(ex, "解析AI返回结果失败返回内容: {Response}", aiResponse);
return BaseResponse<NlpAnalysisResult>.Fail($"AI返回格式错误: {ex.Message}");
}
// 根据关键词查询交易记录
var allRecords = await transactionRepository.QueryBySqlAsync(analysisInfo.Sql);
logger.LogInformation("NLP分析查询到 {Count} 条记录SQL: {Sql}", allRecords.Count, analysisInfo.Sql);
// 为每条记录预设分类
var recordsWithClassify = allRecords.Select(r => new TransactionRecordWithClassify
{
Id = r.Id,
Reason = r.Reason ?? string.Empty,
Amount = r.Amount,
Balance = r.Balance,
Card = r.Card ?? string.Empty,
OccurredAt = r.OccurredAt,
CreateTime = r.CreateTime,
ImportFrom = r.ImportFrom ?? string.Empty,
RefundAmount = r.RefundAmount,
UpsetedType = analysisInfo.TargetType,
UpsetedClassify = analysisInfo.TargetClassify,
TargetType = r.Type,
TargetClassify = r.Classify ?? string.Empty
}).ToList();
return new BaseResponse<NlpAnalysisResult>
{
Success = true,
Data = new NlpAnalysisResult
{
Records = recordsWithClassify,
TargetType = analysisInfo.TargetType,
TargetClassify = analysisInfo.TargetClassify,
SearchKeyword = analysisInfo.Sql
}
};
}
catch (Exception ex)
{
logger.LogError(ex, "NLP分析失败用户输入: {Input}", request.UserInput);
return BaseResponse<NlpAnalysisResult>.Fail($"NLP分析失败: {ex.Message}");
}
}
private async Task WriteEventAsync(string eventType, string data)
{
var message = $"event: {eventType}\ndata: {data}\n\n";
@@ -458,8 +656,7 @@ public record CreateTransactionDto(
string? Reason,
decimal Amount,
TransactionType Type,
string? Classify,
string? SubClassify
string? Classify
);
/// <summary>
@@ -471,8 +668,7 @@ public record UpdateTransactionDto(
decimal Amount,
decimal Balance,
TransactionType Type,
string? Classify,
string? SubClassify
string? Classify
);
/// <summary>
@@ -488,7 +684,7 @@ public record DailyStatisticsDto(
/// 智能分类请求DTO
/// </summary>
public record SmartClassifyRequest(
int PageSize = 10
List<long>? TransactionIds = null
);
/// <summary>
@@ -497,5 +693,67 @@ public record SmartClassifyRequest(
public record BatchUpdateClassifyItem(
long Id,
string? Classify,
string? SubClassify
);
TransactionType? Type = null
);
/// <summary>
/// 按摘要批量更新DTO
/// </summary>
public record BatchUpdateByReasonDto(
string Reason,
TransactionType Type,
string Classify
);
/// <summary>
/// NLP分析请求DTO
/// </summary>
public record NlpAnalysisRequest(
string UserInput
);
/// <summary>
/// NLP分析结果DTO
/// </summary>
public record NlpAnalysisResult
{
public List<TransactionRecordWithClassify> Records { get; set; } = new();
public TransactionType TargetType { get; set; }
public string TargetClassify { get; set; } = string.Empty;
public string SearchKeyword { get; set; } = string.Empty;
}
/// <summary>
/// 带分类信息的交易记录DTO
/// </summary>
public record TransactionRecordWithClassify
{
public long Id { get; set; }
public string Reason { get; set; } = string.Empty;
public decimal Amount { get; set; }
public decimal Balance { get; set; }
public string Card { get; set; } = string.Empty;
public DateTime OccurredAt { get; set; }
public DateTime CreateTime { get; set; }
public string ImportFrom { get; set; } = string.Empty;
public decimal RefundAmount { get; set; }
public TransactionType UpsetedType { get; set; }
public string UpsetedClassify { get; set; } = string.Empty;
public TransactionType TargetType { get; set; }
public string TargetClassify { get; set; } = string.Empty;
}
/// <summary>
/// AI分析信息DTO内部使用
/// </summary>
public record NlpAnalysisInfo
{
[JsonPropertyName("sql")]
public string Sql { get; set; } = string.Empty;
[JsonPropertyName("targetType")]
public TransactionType TargetType { get; set; }
[JsonPropertyName("targetClassify")]
public string TargetClassify { get; set; } = string.Empty;
}