Files
EmailBill/WebApi/Controllers/TransactionRecordController.cs
孙诚 cb11d80d1f
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
功能添加
2025-12-26 15:21:31 +08:00

760 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
public class TransactionRecordController(
ITransactionRecordRepository transactionRepository,
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
ILogger<TransactionRecordController> logger
) : ControllerBase
{
/// <summary>
/// 获取交易记录列表(分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<Entity.TransactionRecord>> GetListAsync(
[FromQuery] DateTime? lastOccurredAt = null,
[FromQuery] long? lastId = null,
[FromQuery] string? searchKeyword = null
)
{
try
{
var (list, lastTime, lastIdResult) = await transactionRepository.GetPagedListAsync(lastOccurredAt, lastId, 20, searchKeyword);
var total = await transactionRepository.GetTotalCountAsync();
return new PagedResponse<Entity.TransactionRecord>
{
Success = true,
Data = list.ToArray(),
Total = (int)total,
LastId = lastIdResult,
LastTime = lastTime
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易记录列表失败,时间: {LastTime}, ID: {LastId}", lastOccurredAt, lastId);
return PagedResponse<Entity.TransactionRecord>.Fail($"获取交易记录列表失败: {ex.Message}");
}
}
/// <summary>
/// 根据ID获取交易记录详情
/// </summary>
[HttpGet("{id}")]
public async Task<BaseResponse<Entity.TransactionRecord>> GetByIdAsync(long id)
{
try
{
var transaction = await transactionRepository.GetByIdAsync(id);
if (transaction == null)
{
return BaseResponse<Entity.TransactionRecord>.Fail("交易记录不存在");
}
return new BaseResponse<Entity.TransactionRecord>
{
Success = true,
Data = transaction
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易记录详情失败交易ID: {TransactionId}", id);
return BaseResponse<Entity.TransactionRecord>.Fail($"获取交易记录详情失败: {ex.Message}");
}
}
/// <summary>
/// 根据邮件ID获取交易记录列表
/// </summary>
[HttpGet("{emailId}")]
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetByEmailIdAsync(long emailId)
{
try
{
var transactions = await transactionRepository.GetByEmailIdAsync(emailId);
return new BaseResponse<List<Entity.TransactionRecord>>
{
Success = true,
Data = transactions
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取邮件交易记录失败邮件ID: {EmailId}", emailId);
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取邮件交易记录失败: {ex.Message}");
}
}
/// <summary>
/// 创建交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> CreateAsync([FromBody] CreateTransactionDto dto)
{
try
{
// 解析日期字符串
if (!DateTime.TryParse(dto.OccurredAt, out var occurredAt))
{
return BaseResponse.Fail("交易时间格式不正确");
}
var transaction = new Entity.TransactionRecord
{
OccurredAt = occurredAt,
Reason = dto.Reason ?? string.Empty,
Amount = dto.Amount,
Type = dto.Type,
Classify = dto.Classify ?? string.Empty,
ImportFrom = "手动录入",
EmailMessageId = 0 // 手动录入的记录EmailMessageId 设为 0
};
var result = await transactionRepository.AddAsync(transaction);
if (result)
{
return new BaseResponse
{
Success = true
};
}
else
{
return BaseResponse.Fail("创建交易记录失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "创建交易记录失败,交易信息: {@TransactionDto}", dto);
return BaseResponse.Fail($"创建交易记录失败: {ex.Message}");
}
}
/// <summary>
/// 更新交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> UpdateAsync([FromBody] UpdateTransactionDto dto)
{
try
{
var transaction = await transactionRepository.GetByIdAsync(dto.Id);
if (transaction == null)
{
return BaseResponse.Fail("交易记录不存在");
}
// 更新可编辑字段
transaction.Reason = dto.Reason ?? string.Empty;
transaction.Amount = dto.Amount;
transaction.Balance = dto.Balance;
transaction.Type = dto.Type;
transaction.Classify = dto.Classify ?? string.Empty;
var success = await transactionRepository.UpdateAsync(transaction);
if (success)
{
return new BaseResponse
{
Success = true
};
}
else
{
return BaseResponse.Fail("更新交易记录失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "更新交易记录失败交易ID: {TransactionId}, 交易信息: {@TransactionDto}", dto.Id, dto);
return BaseResponse.Fail($"更新交易记录失败: {ex.Message}");
}
}
/// <summary>
/// 删除交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> DeleteByIdAsync([FromQuery] long id)
{
try
{
var success = await transactionRepository.DeleteAsync(id);
if (success)
{
return new BaseResponse
{
Success = true
};
}
else
{
return BaseResponse.Fail("删除交易记录失败,记录不存在");
}
}
catch (Exception ex)
{
logger.LogError(ex, "删除交易记录失败交易ID: {TransactionId}", id);
return BaseResponse.Fail($"删除交易记录失败: {ex.Message}");
}
}
/// <summary>
/// 获取指定月份每天的消费统计
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<DailyStatisticsDto>>> GetDailyStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
try
{
var statistics = await transactionRepository.GetDailyStatisticsAsync(year, month);
var result = statistics.Select(s => new DailyStatisticsDto(s.Key, s.Value.count, s.Value.amount)).ToList();
return new BaseResponse<List<DailyStatisticsDto>>
{
Success = true,
Data = result
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取日历统计数据失败,年份: {Year}, 月份: {Month}", year, month);
return BaseResponse<List<DailyStatisticsDto>>.Fail($"获取日历统计数据失败: {ex.Message}");
}
}
/// <summary>
/// 获取指定日期的交易记录
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetByDateAsync(
[FromQuery] string date
)
{
try
{
if (!DateTime.TryParse(date, out var targetDate))
{
return BaseResponse<List<Entity.TransactionRecord>>.Fail("日期格式不正确");
}
// 获取当天的开始和结束时间
var startDate = targetDate.Date;
var endDate = startDate.AddDays(1);
var records = await transactionRepository.GetByDateRangeAsync(startDate, endDate);
return new BaseResponse<List<Entity.TransactionRecord>>
{
Success = true,
Data = records
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取指定日期的交易记录失败,日期: {Date}", date);
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取指定日期的交易记录失败: {ex.Message}");
}
}
/// <summary>
/// 获取未分类的账单数量
/// </summary>
[HttpGet]
public async Task<BaseResponse<int>> GetUnclassifiedCountAsync()
{
try
{
var count = await transactionRepository.GetUnclassifiedCountAsync();
return new BaseResponse<int>
{
Success = true,
Data = count
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取未分类账单数量失败");
return BaseResponse<int>.Fail($"获取未分类账单数量失败: {ex.Message}");
}
}
/// <summary>
/// 获取未分类的账单列表
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetUnclassifiedAsync([FromQuery] int pageSize = 10)
{
try
{
var records = await transactionRepository.GetUnclassifiedAsync(pageSize);
return new BaseResponse<List<Entity.TransactionRecord>>
{
Success = true,
Data = records
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取未分类账单列表失败");
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取未分类账单列表失败: {ex.Message}");
}
}
/// <summary>
/// 智能分类 - 使用AI对账单进行分类流式响应
/// </summary>
[HttpPost]
public async Task SmartClassifyAsync([FromBody] SmartClassifyRequest request)
{
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
try
{
// 验证账单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", "找不到指定的账单");
return;
}
// 获取所有分类
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 billsInfo = string.Join("\n", records.Select((r, i) =>
$"{i + 1}. ID={r.Id}, 摘要={r.Reason}, 金额={r.Amount}, 类型={GetTypeName(r.Type)}"));
var systemPrompt = $$"""
你是一个专业的账单分类助手。请根据提供的账单信息和分类列表,为每个账单选择最合适的分类。
可用的分类列表:
{{categoryInfo}}
分类规则:
1. 根据账单的摘要和金额,选择最匹配的分类
2. 如果无法确定分类,可以选择""其他""
请对每个账单进行分类,每次输出一个账单的分类结果,格式如下:
{"id": 账单ID, "type": 0:支出/1:收入/2:不计入收支(Type为Number枚举值) ,"classify": 分类名称}
JSON
""";
var userPrompt = $@"请为以下账单进行分类:
{billsInfo}
请逐个输出分类结果。";
// 流式调用AI
await WriteEventAsync("start", $"开始分类 {records.Count} 条账单");
await foreach (var chunk in openAiService.ChatStreamAsync(systemPrompt, userPrompt))
{
await WriteEventAsync("data", chunk);
}
await WriteEventAsync("end", "分类完成");
}
catch (Exception ex)
{
logger.LogError(ex, "智能分类失败");
await WriteEventAsync("error", $"智能分类失败: {ex.Message}");
}
}
/// <summary>
/// 批量更新账单分类
/// </summary>
[HttpPost]
public async Task<BaseResponse> BatchUpdateClassifyAsync([FromBody] List<BatchUpdateClassifyItem> items)
{
try
{
var successCount = 0;
var failCount = 0;
foreach (var item in items)
{
var record = await transactionRepository.GetByIdAsync(item.Id);
if (record != null)
{
record.Classify = item.Classify ?? string.Empty;
// 如果提供了Type也更新Type
if (item.Type.HasValue)
{
record.Type = item.Type.Value;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
successCount++;
else
failCount++;
}
else
{
failCount++;
}
}
return new BaseResponse
{
Success = true,
Message = $"批量更新完成,成功 {successCount} 条,失败 {failCount} 条"
};
}
catch (Exception ex)
{
logger.LogError(ex, "批量更新分类失败");
return BaseResponse.Fail($"批量更新分类失败: {ex.Message}");
}
}
/// <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";
await Response.WriteAsync(message);
await Response.Body.FlushAsync();
}
private static string GetTypeName(TransactionType type)
{
return type switch
{
TransactionType.Expense => "支出",
TransactionType.Income => "收入",
TransactionType.None => "不计入收支",
_ => "未知"
};
}
}
/// <summary>
/// 创建交易记录DTO
/// </summary>
public record CreateTransactionDto(
string OccurredAt,
string? Reason,
decimal Amount,
TransactionType Type,
string? Classify
);
/// <summary>
/// 更新交易记录DTO
/// </summary>
public record UpdateTransactionDto(
long Id,
string? Reason,
decimal Amount,
decimal Balance,
TransactionType Type,
string? Classify
);
/// <summary>
/// 日历统计响应DTO
/// </summary>
public record DailyStatisticsDto(
string Date,
int Count,
decimal Amount
);
/// <summary>
/// 智能分类请求DTO
/// </summary>
public record SmartClassifyRequest(
List<long>? TransactionIds = null
);
/// <summary>
/// 批量更新分类项DTO
/// </summary>
public record BatchUpdateClassifyItem(
long Id,
string? Classify,
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;
}