Files
EmailBill/WebApi/Controllers/TransactionRecordController.cs
SunCheng 3ed9cf5ebd
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
重构: 将 LogCleanupService 转为 Quartz Job 服务
- 创建 LogCleanupJob 替代 LogCleanupService (BackgroundService)
- 在 Expand.cs 中注册 LogCleanupJob (每天凌晨2点执行, 保留30天日志)
- 从 Program.cs 移除 LogCleanupService 的 HostedService 注册
- 删除 Service/LogCleanupService.cs
- 删除 Service/PeriodicBillBackgroundService.cs (已无用的重复服务)

所有后台任务现在统一通过 Quartz.NET 管理, 支持运行时控制
2026-01-28 11:19:23 +08:00

791 lines
24 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.
using Service.AI;
using Service.Transaction;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
public class TransactionRecordController(
ITransactionRecordRepository transactionRepository,
ITransactionStatisticsService transactionStatisticsService,
ISmartHandleService smartHandleService,
ILogger<TransactionRecordController> logger,
IConfigService configService
) : ControllerBase
{
/// <summary>
/// 获取交易记录列表(分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<TransactionRecord>> GetListAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? searchKeyword = null,
[FromQuery] string? classify = null,
[FromQuery] int? type = null,
[FromQuery] int? year = null,
[FromQuery] int? month = null,
[FromQuery] DateTime? startDate = null,
[FromQuery] DateTime? endDate = null,
[FromQuery] string? reason = null,
[FromQuery] bool sortByAmount = false
)
{
try
{
string[]? classifies = string.IsNullOrWhiteSpace(classify)
? null
: classify.Split(',', StringSplitOptions.RemoveEmptyEntries);
TransactionType? transactionType = type.HasValue ? (TransactionType)type.Value : null;
var list = await transactionRepository.QueryAsync(
year: year,
month: month,
startDate: startDate,
endDate: endDate,
type: transactionType,
classifies: classifies,
searchKeyword: searchKeyword,
reason: reason,
pageIndex: pageIndex,
pageSize: pageSize,
sortByAmount: sortByAmount);
var total = await transactionRepository.CountAsync(
year: year,
month: month,
startDate: startDate,
endDate: endDate,
type: transactionType,
classifies: classifies,
searchKeyword: searchKeyword,
reason: reason);
return new PagedResponse<TransactionRecord>
{
Success = true,
Data = list.ToArray(),
Total = (int)total
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易记录列表失败,页码: {PageIndex}, 页大小: {PageSize}", pageIndex, pageSize);
return PagedResponse<TransactionRecord>.Fail($"获取交易记录列表失败: {ex.Message}");
}
}
/// <summary>
/// 获取待确认分类的交易记录列表
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionRecord>>> GetUnconfirmedListAsync()
{
try
{
var list = await transactionRepository.GetUnconfirmedRecordsAsync();
return list.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取待确认分类交易列表失败");
return $"获取待确认分类交易列表失败: {ex.Message}".Fail<List<TransactionRecord>>();
}
}
/// <summary>
/// 全部确认待确认的交易分类
/// </summary>
[HttpPost]
public async Task<BaseResponse<int>> ConfirmAllUnconfirmedAsync([FromBody] ConfirmAllUnconfirmedRequestDto request)
{
try
{
var count = await transactionRepository.ConfirmAllUnconfirmedAsync(request.Ids);
return count.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "全部确认待确认分类失败");
return $"全部确认待确认分类失败: {ex.Message}".Fail<int>();
}
}
/// <summary>
/// 根据ID获取交易记录详情
/// </summary>
[HttpGet("{id}")]
public async Task<BaseResponse<TransactionRecord>> GetByIdAsync(long id)
{
try
{
var transaction = await transactionRepository.GetByIdAsync(id);
if (transaction == null)
{
return "交易记录不存在".Fail<TransactionRecord>();
}
return transaction.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易记录详情失败交易ID: {TransactionId}", id);
return $"获取交易记录详情失败: {ex.Message}".Fail<TransactionRecord>();
}
}
/// <summary>
/// 根据邮件ID获取交易记录列表
/// </summary>
[HttpGet("{emailId}")]
public async Task<BaseResponse<List<TransactionRecord>>> GetByEmailIdAsync(long emailId)
{
try
{
var transactions = await transactionRepository.GetByEmailIdAsync(emailId);
return transactions.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取邮件交易记录失败邮件ID: {EmailId}", emailId);
return $"获取邮件交易记录失败: {ex.Message}".Fail<List<TransactionRecord>>();
}
}
/// <summary>
/// 创建交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> CreateAsync([FromBody] CreateTransactionDto dto)
{
try
{
// 解析日期字符串
if (!DateTime.TryParse(dto.OccurredAt, out var occurredAt))
{
return "交易时间格式不正确".Fail();
}
var transaction = new TransactionRecord
{
OccurredAt = occurredAt,
Reason = dto.Reason ?? string.Empty,
Amount = dto.Amount,
Type = dto.Type,
Classify = dto.Classify ?? string.Empty,
ImportFrom = "手动录入",
ImportNo = Guid.NewGuid().ToString("N"),
Card = "手动",
EmailMessageId = 0 // 手动录入的记录EmailMessageId 设为 0
};
var result = await transactionRepository.AddAsync(transaction);
if (result)
{
return BaseResponse.Done();
}
return "创建交易记录失败".Fail();
}
catch (Exception ex)
{
logger.LogError(ex, "创建交易记录失败,交易信息: {@TransactionDto}", dto);
return $"创建交易记录失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 更新交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> UpdateAsync([FromBody] UpdateTransactionDto dto)
{
try
{
var transaction = await transactionRepository.GetByIdAsync(dto.Id);
if (transaction == null)
{
return "交易记录不存在".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;
// 清除待确认状态
transaction.UnconfirmedClassify = null;
transaction.UnconfirmedType = null;
var success = await transactionRepository.UpdateAsync(transaction);
if (success)
{
return BaseResponse.Done();
}
return "更新交易记录失败".Fail();
}
catch (Exception ex)
{
logger.LogError(ex, "更新交易记录失败交易ID: {TransactionId}, 交易信息: {@TransactionDto}", dto.Id, dto);
return $"更新交易记录失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 删除交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> DeleteByIdAsync([FromQuery] long id)
{
try
{
var success = await transactionRepository.DeleteAsync(id);
if (success)
{
return BaseResponse.Done();
}
return "删除交易记录失败,记录不存在".Fail();
}
catch (Exception ex)
{
logger.LogError(ex, "删除交易记录失败交易ID: {TransactionId}", id);
return $"删除交易记录失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 获取累积余额统计数据(用于余额卡片图表)
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<BalanceStatisticsDto>>> GetBalanceStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
try
{
// 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
// 获取每日统计数据
var statistics = await transactionStatisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
// 按日期排序并计算累积余额
var sortedStats = statistics.OrderBy(s => s.Key).ToList();
var result = new List<BalanceStatisticsDto>();
decimal cumulativeBalance = 0;
foreach (var item in sortedStats)
{
decimal dailyBalance = item.Value.income - item.Value.expense;
cumulativeBalance += dailyBalance;
result.Add(new BalanceStatisticsDto(
item.Key,
cumulativeBalance
));
}
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取累积余额统计失败,年份: {Year}, 月份: {Month}", year, month);
return $"获取累积余额统计失败: {ex.Message}".Fail<List<BalanceStatisticsDto>>();
}
}
/// <summary>
/// 获取指定月份每天的消费统计
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<DailyStatisticsDto>>> GetDailyStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
try
{
// 获取存款分类
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await transactionStatisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
var result = statistics.Select(s => new DailyStatisticsDto(
s.Key,
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取日历统计数据失败,年份: {Year}, 月份: {Month}", year, month);
return $"获取日历统计数据失败: {ex.Message}".Fail<List<DailyStatisticsDto>>();
}
}
/// <summary>
/// 获取月度统计数据
/// </summary>
[HttpGet]
public async Task<BaseResponse<MonthlyStatistics>> GetMonthlyStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
try
{
var statistics = await transactionStatisticsService.GetMonthlyStatisticsAsync(year, month);
return statistics.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取月度统计数据失败,年份: {Year}, 月份: {Month}", year, month);
return $"获取月度统计数据失败: {ex.Message}".Fail<MonthlyStatistics>();
}
}
/// <summary>
/// 获取分类统计数据
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<CategoryStatistics>>> GetCategoryStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] TransactionType type
)
{
try
{
var statistics = await transactionStatisticsService.GetCategoryStatisticsAsync(year, month, type);
return statistics.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取分类统计数据失败,年份: {Year}, 月份: {Month}, 类型: {Type}", year, month, type);
return $"获取分类统计数据失败: {ex.Message}".Fail<List<CategoryStatistics>>();
}
}
/// <summary>
/// 获取趋势统计数据
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TrendStatistics>>> GetTrendStatisticsAsync(
[FromQuery] int startYear,
[FromQuery] int startMonth,
[FromQuery] int monthCount = 6
)
{
try
{
var statistics = await transactionStatisticsService.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
return statistics.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取趋势统计数据失败,开始年份: {Year}, 开始月份: {Month}, 月份数: {Count}", startYear, startMonth,
monthCount);
return $"获取趋势统计数据失败: {ex.Message}".Fail<List<TrendStatistics>>();
}
}
/// <summary>
/// 智能分析账单(流式输出)
/// </summary>
public async Task AnalyzeBillAsync([FromBody] BillAnalysisRequest request)
{
Response.ContentType = "text/event-stream";
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
if (string.IsNullOrWhiteSpace(request.UserInput))
{
await WriteEventAsync("<div class='error-message'>请输入分析内容</div>");
return;
}
await smartHandleService.AnalyzeBillAsync(request.UserInput, async void (chunk) =>
{
try
{
await WriteEventAsync(chunk);
}
catch (Exception e)
{
logger.LogError(e, "流式写入账单分析结果失败");
}
});
}
/// <summary>
/// 获取指定日期的交易记录
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionRecord>>> GetByDateAsync([FromQuery] string date)
{
try
{
if (!DateTime.TryParse(date, out var targetDate))
{
return "日期格式不正确".Fail<List<TransactionRecord>>();
}
// 获取当天的开始和结束时间
var startDate = targetDate.Date;
var endDate = startDate.AddDays(1);
var records = await transactionRepository.QueryAsync(startDate: startDate, endDate: endDate);
return records.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取指定日期的交易记录失败,日期: {Date}", date);
return $"获取指定日期的交易记录失败: {ex.Message}".Fail<List<TransactionRecord>>();
}
}
/// <summary>
/// 获取未分类的账单数量
/// </summary>
[HttpGet]
public async Task<BaseResponse<int>> GetUnclassifiedCountAsync()
{
try
{
var count = (int)await transactionRepository.CountAsync();
return count.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取未分类账单数量失败");
return $"获取未分类账单数量失败: {ex.Message}".Fail<int>();
}
}
/// <summary>
/// 获取未分类的账单列表
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionRecord>>> GetUnclassifiedAsync([FromQuery] int pageSize = 10)
{
try
{
var records = await transactionRepository.GetUnclassifiedAsync(pageSize);
return records.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取未分类账单列表失败");
return $"获取未分类账单列表失败: {ex.Message}".Fail<List<TransactionRecord>>();
}
}
/// <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");
// 验证账单ID列表
if (request.TransactionIds == null || request.TransactionIds.Count == 0)
{
await WriteEventAsync("error", "请提供要分类的账单ID");
return;
}
await smartHandleService.SmartClassifyAsync(request.TransactionIds.ToArray(), async void (chunk) =>
{
try
{
var (eventType, content) = chunk;
await TrySetUnconfirmedAsync(eventType, content);
await WriteEventAsync(eventType, content);
}
catch (Exception e)
{
logger.LogError(e, "流式写入智能分类结果失败");
}
});
await Response.Body.FlushAsync();
}
private async Task TrySetUnconfirmedAsync(string eventType, string content)
{
if (eventType != "data")
{
return;
}
try
{
var jsonObject = JsonSerializer.Deserialize<JsonObject>(content);
var id = jsonObject?["id"]?.GetValue<long>() ?? 0;
var classify = jsonObject?["Classify"]?.GetValue<string>() ?? string.Empty;
var typeValue = jsonObject?["Type"]?.GetValue<int>() ?? -1;
if (id == 0 || typeValue == -1 || string.IsNullOrEmpty(classify))
{
logger.LogWarning("解析智能分类结果时,发现无效数据,内容: {Content}", content);
return;
}
var record = await transactionRepository.GetByIdAsync(id);
if (record == null)
{
logger.LogWarning("解析智能分类结果时未找到对应的交易记录ID: {Id}", id);
return;
}
record.UnconfirmedClassify = classify;
record.UnconfirmedType = (TransactionType)typeValue;
var success = await transactionRepository.UpdateAsync(record);
if (!success)
{
logger.LogWarning("解析智能分类结果时更新交易记录失败ID: {Id}", id);
}
}
catch (Exception ex)
{
logger.LogError(ex, "解析智能分类结果失败,内容: {Content}", content);
}
}
/// <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;
}
if (!string.IsNullOrEmpty(record.Classify))
{
record.UnconfirmedClassify = null;
}
if (record.Type == item.Type)
{
record.UnconfirmedType = TransactionType.None;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
successCount++;
else
failCount++;
}
else
{
failCount++;
}
}
return $"批量更新完成,成功 {successCount} 条,失败 {failCount} 条".Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "批量更新分类失败");
return $"批量更新分类失败: {ex.Message}".Fail();
}
}
/// <summary>
/// 获取按交易摘要分组的统计信息(支持分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<ReasonGroupDto>> GetReasonGroupsAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 20)
{
try
{
var (list, total) = await transactionStatisticsService.GetReasonGroupsAsync(pageIndex, pageSize);
return new PagedResponse<ReasonGroupDto>
{
Success = true,
Data = list.ToArray(),
Total = total
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取交易摘要分组失败");
return PagedResponse<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 count.Ok($"成功更新 {count} 条记录");
}
catch (Exception ex)
{
logger.LogError(ex, "按摘要批量更新分类失败,摘要: {Reason}", dto.Reason);
return $"按摘要批量更新分类失败: {ex.Message}".Fail<int>();
}
}
/// <summary>
/// 一句话录账解析
/// </summary>
[HttpPost]
public async Task<BaseResponse<TransactionParseResult>> ParseOneLine([FromBody] ParseOneLineRequestDto request)
{
if (string.IsNullOrEmpty(request.Text))
{
return "请求参数缺失text".Fail<TransactionParseResult>();
}
try
{
var result = await smartHandleService.ParseOneLineBillAsync(request.Text);
if (result == null)
{
return "AI解析失败".Fail<TransactionParseResult>();
}
return result.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "一句话录账解析失败,文本: {Text}", request.Text);
return ("AI解析失败: " + ex.Message).Fail<TransactionParseResult>();
}
}
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 async Task WriteEventAsync(string data)
{
var message = $"data: {data}\n\n";
await Response.WriteAsync(message);
await Response.Body.FlushAsync();
}
}
/// <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 Expense,
decimal Income,
decimal Balance
);
/// <summary>
/// 累积余额统计DTO
/// </summary>
public record BalanceStatisticsDto(
string Date,
decimal CumulativeBalance
);
/// <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>
/// 账单分析请求DTO
/// </summary>
public record BillAnalysisRequest(
string UserInput
);
public record ParseOneLineRequestDto(
string Text
);
public record ConfirmAllUnconfirmedRequestDto(
long[] Ids
);