first commot
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 8s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s

This commit is contained in:
孙诚
2025-12-25 11:20:56 +08:00
commit 4526cc6396
104 changed files with 11070 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
namespace WebApi.Controllers;
/// <summary>
/// 账单导入控制器
/// </summary>
[ApiController]
[Route("api/[controller]/[action]")]
public class BillImportController(
ILogger<BillImportController> logger,
IImportService importService
) : ControllerBase
{
/// <summary>
/// 上传账单文件
/// </summary>
/// <param name="file">账单文件</param>
/// <param name="type">账单类型Alipay | WeChat</param>
/// <returns></returns>
[HttpPost]
public async Task<BaseResponse<object>> UploadFile(
[FromForm] IFormFile file,
[FromForm] string type
)
{
try
{
// 验证参数
if (file.Length == 0)
{
return BaseResponse<object>.Fail("请选择要上传的文件");
}
if (string.IsNullOrWhiteSpace(type) || (type != "Alipay" && type != "WeChat"))
{
return BaseResponse<object>.Fail("账单类型参数错误,必须是 Alipay 或 WeChat");
}
// 验证文件类型
var allowedExtensions = new[] { ".csv", ".xlsx", ".xls" };
var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowedExtensions.Contains(fileExtension))
{
return BaseResponse<object>.Fail("只支持 CSV 或 Excel 文件格式");
}
// 验证文件大小10MB限制
const long maxFileSize = 10 * 1024 * 1024;
if (file.Length > maxFileSize)
{
return BaseResponse<object>.Fail("文件大小不能超过 10MB");
}
// 生成唯一文件名
var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}{fileExtension}";
// 保存文件
var ok = false;
var message = string.Empty;
await using (var stream = new MemoryStream())
{
await file.CopyToAsync(stream);
if (type == "Alipay")
{
(ok, message) = await importService.ImportAlipayAsync(stream, fileExtension);
}
else if (type == "WeChat")
{
(ok, message) = await importService.ImportWeChatAsync(stream, fileExtension);
}
}
return new BaseResponse<object>
{
Success = ok,
Message = message
};
}
catch (Exception ex)
{
logger.LogError(ex, "文件上传失败,类型: {Type}", type);
return BaseResponse<object>.Fail($"文件上传失败: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,41 @@
namespace WebApi.Controllers.Dto;
public class BaseResponse
{
/// <summary>
/// 是否成功
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 错误消息
/// </summary>
public string? Message { get; set; }
public static BaseResponse Fail(string message)
{
return new BaseResponse
{
Success = false,
Message = message
};
}
}
public class BaseResponse<T> : BaseResponse
{
/// <summary>
/// 返回数据
/// </summary>
public T? Data { get; set; }
public new static BaseResponse<T> Fail(string message)
{
return new BaseResponse<T>
{
Success = false,
Message = message
};
}
}

View File

@@ -0,0 +1,40 @@
namespace WebApi.Controllers.Dto;
/// <summary>
/// 邮件消息DTO包含额外的统计信息
/// </summary>
public class EmailMessageDto
{
public long Id { get; set; }
public string Subject { get; set; } = string.Empty;
public string From { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public string HtmlBody { get; set; } = string.Empty;
public DateTime ReceivedDate { get; set; }
public DateTime CreateTime { get; set; }
public DateTime? UpdateTime { get; set; }
/// <summary>
/// 已解析的账单数量
/// </summary>
public int TransactionCount { get; set; }
/// <summary>
/// 从实体转换为DTO
/// </summary>
public static EmailMessageDto FromEntity(Entity.EmailMessage entity, int transactionCount = 0)
{
return new EmailMessageDto
{
Id = entity.Id,
Subject = entity.Subject,
From = entity.From,
Body = entity.Body,
HtmlBody = entity.HtmlBody,
ReceivedDate = entity.ReceivedDate,
CreateTime = entity.CreateTime,
UpdateTime = entity.UpdateTime,
TransactionCount = transactionCount
};
}
}

View File

@@ -0,0 +1,25 @@
namespace WebApi.Controllers.Dto;
public class PagedResponse<T> : BaseResponse<T[]>
{
public long LastId { get; set; } = 0;
/// <summary>
/// 最后一条记录的时间(用于游标分页)
/// </summary>
public DateTime? LastTime { get; set; }
/// <summary>
/// 总记录数
/// </summary>
public int Total { get; set; }
public new static PagedResponse<T> Fail(string message)
{
return new PagedResponse<T>
{
Success = false,
Message = message
};
}
}

View File

@@ -0,0 +1,161 @@
namespace WebApi.Controllers.EmailMessage;
[ApiController]
[Route("api/[controller]/[action]")]
public class EmailMessageController(
IEmailMessageRepository emailRepository,
ITransactionRecordRepository transactionRepository,
ILogger<EmailMessageController> logger,
IEmailHandleService emailHandleService,
IEmailBackgroundService emailBackgroundService
) : ControllerBase
{
/// <summary>
/// 获取邮件列表(分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<EmailMessageDto>> GetListAsync(
[FromQuery] DateTime? lastReceivedDate = null,
[FromQuery] long? lastId = null
)
{
try
{
var (list, lastTime, lastIdResult) = await emailRepository.GetPagedListAsync(lastReceivedDate, lastId);
var total = await emailRepository.GetTotalCountAsync();
// 为每个邮件获取账单数量
var emailDtos = new List<EmailMessageDto>();
foreach (var email in list)
{
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(email.Id);
emailDtos.Add(EmailMessageDto.FromEntity(email, transactionCount));
}
return new PagedResponse<EmailMessageDto>
{
Success = true,
Data = emailDtos.ToArray(),
Total = (int)total,
LastId = lastIdResult,
LastTime = lastTime
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取邮件列表失败,时间: {LastTime}, ID: {LastId}", lastReceivedDate, lastId);
return PagedResponse<EmailMessageDto>.Fail($"获取邮件列表失败: {ex.Message}");
}
}
/// <summary>
/// 根据ID获取邮件详情
/// </summary>
[HttpGet("{id}")]
public async Task<BaseResponse<EmailMessageDto>> GetByIdAsync(long id)
{
try
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
return BaseResponse<EmailMessageDto>.Fail("邮件不存在");
}
// 获取账单数量
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(id);
var emailDto = EmailMessageDto.FromEntity(email, transactionCount);
return new BaseResponse<EmailMessageDto>
{
Success = true,
Data = emailDto
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取邮件详情失败邮件ID: {EmailId}", id);
return BaseResponse<EmailMessageDto>.Fail($"获取邮件详情失败: {ex.Message}");
}
}
public async Task<BaseResponse> DeleteByIdAsync(long id)
{
try
{
var success = await emailRepository.DeleteAsync(id);
if (success)
{
return new BaseResponse
{
Success = true
};
}
else
{
return BaseResponse.Fail("删除邮件失败,邮件不存在");
}
}
catch (Exception ex)
{
logger.LogError(ex, "删除邮件失败邮件ID: {EmailId}", id);
return BaseResponse.Fail($"删除邮件失败: {ex.Message}");
}
}
/// <summary>
/// 重新分析邮件并刷新交易记录
/// </summary>
[HttpPost]
public async Task<BaseResponse> RefreshTransactionRecordsAsync([FromQuery] long id)
{
try
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
return BaseResponse.Fail("邮件不存在");
}
var success = await emailHandleService.RefreshTransactionRecordsAsync(id);
if (success)
{
return new BaseResponse
{
Success = true
};
}
else
{
return BaseResponse.Fail("重新分析失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "重新分析邮件失败邮件ID: {EmailId}", id);
return BaseResponse.Fail($"重新分析失败: {ex.Message}");
}
}
/// <summary>
/// 立即同步邮件
/// </summary>
[HttpPost]
public async Task<BaseResponse> SyncEmailsAsync()
{
try
{
await emailBackgroundService.SyncEmailsAsync();
return new BaseResponse
{
Success = true,
Message = "同步成功"
};
}
catch (Exception ex)
{
logger.LogError(ex, "同步邮件失败");
return BaseResponse.Fail($"同步邮件失败: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,301 @@
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
public class TransactionCategoryController(
ITransactionCategoryRepository categoryRepository,
ILogger<TransactionCategoryController> logger
) : ControllerBase
{
/// <summary>
/// 获取分类树(支持按类型筛选)
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionCategoryTreeDto>>> GetTreeAsync([FromQuery] TransactionType? type = null)
{
try
{
var tree = await categoryRepository.GetCategoryTreeAsync(type);
return new BaseResponse<List<TransactionCategoryTreeDto>>
{
Success = true,
Data = tree
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取分类树失败");
return BaseResponse<List<TransactionCategoryTreeDto>>.Fail($"获取分类树失败: {ex.Message}");
}
}
/// <summary>
/// 获取顶级分类列表(按类型)
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionCategory>>> GetTopLevelAsync([FromQuery] TransactionType type)
{
try
{
var categories = await categoryRepository.GetTopLevelCategoriesByTypeAsync(type);
return new BaseResponse<List<TransactionCategory>>
{
Success = true,
Data = categories
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取顶级分类失败, Type: {Type}", type);
return BaseResponse<List<TransactionCategory>>.Fail($"获取顶级分类失败: {ex.Message}");
}
}
/// <summary>
/// 获取子分类列表
/// </summary>
[HttpGet]
public async Task<BaseResponse<List<TransactionCategory>>> GetChildrenAsync([FromQuery] long parentId)
{
try
{
var categories = await categoryRepository.GetChildCategoriesAsync(parentId);
return new BaseResponse<List<TransactionCategory>>
{
Success = true,
Data = categories
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取子分类失败, ParentId: {ParentId}", parentId);
return BaseResponse<List<TransactionCategory>>.Fail($"获取子分类失败: {ex.Message}");
}
}
/// <summary>
/// 根据ID获取分类详情
/// </summary>
[HttpGet("{id}")]
public async Task<BaseResponse<TransactionCategory>> GetByIdAsync(long id)
{
try
{
var category = await categoryRepository.GetByIdAsync(id);
if (category == null)
{
return BaseResponse<TransactionCategory>.Fail("分类不存在");
}
return new BaseResponse<TransactionCategory>
{
Success = true,
Data = category
};
}
catch (Exception ex)
{
logger.LogError(ex, "获取分类详情失败, Id: {Id}", id);
return BaseResponse<TransactionCategory>.Fail($"获取分类详情失败: {ex.Message}");
}
}
/// <summary>
/// 创建分类
/// </summary>
[HttpPost]
public async Task<BaseResponse<long>> CreateAsync([FromBody] CreateCategoryDto dto)
{
try
{
// 检查同名分类
var existing = await categoryRepository.GetByNameAndParentAsync(dto.Name, dto.ParentId, dto.Type);
if (existing != null)
{
return BaseResponse<long>.Fail("同级已存在相同名称的分类");
}
var category = new TransactionCategory
{
Name = dto.Name,
ParentId = dto.ParentId,
Type = dto.Type,
Level = dto.Level,
SortOrder = dto.SortOrder,
Icon = dto.Icon,
Remark = dto.Remark
};
var result = await categoryRepository.AddAsync(category);
if (result)
{
return new BaseResponse<long>
{
Success = true,
Data = category.Id
};
}
else
{
return BaseResponse<long>.Fail("创建分类失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "创建分类失败, Dto: {@Dto}", dto);
return BaseResponse<long>.Fail($"创建分类失败: {ex.Message}");
}
}
/// <summary>
/// 更新分类
/// </summary>
[HttpPost]
public async Task<BaseResponse> UpdateAsync([FromBody] UpdateCategoryDto dto)
{
try
{
var category = await categoryRepository.GetByIdAsync(dto.Id);
if (category == null)
{
return BaseResponse.Fail("分类不存在");
}
// 如果修改了名称,检查同名
if (category.Name != dto.Name)
{
var existing = await categoryRepository.GetByNameAndParentAsync(dto.Name, category.ParentId, category.Type);
if (existing != null && existing.Id != dto.Id)
{
return BaseResponse.Fail("同级已存在相同名称的分类");
}
}
category.Name = dto.Name;
category.SortOrder = dto.SortOrder;
category.Icon = dto.Icon;
category.IsEnabled = dto.IsEnabled;
category.Remark = dto.Remark;
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (success)
{
return new BaseResponse { Success = true };
}
else
{
return BaseResponse.Fail("更新分类失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "更新分类失败, Dto: {@Dto}", dto);
return BaseResponse.Fail($"更新分类失败: {ex.Message}");
}
}
/// <summary>
/// 删除分类
/// </summary>
[HttpPost]
public async Task<BaseResponse> DeleteAsync([FromQuery] long id)
{
try
{
// 检查是否有子分类
var children = await categoryRepository.GetChildCategoriesAsync(id);
if (children.Any())
{
return BaseResponse.Fail("该分类下存在子分类,无法删除");
}
// 检查是否被使用
var inUse = await categoryRepository.IsCategoryInUseAsync(id);
if (inUse)
{
return BaseResponse.Fail("该分类已被使用,无法删除");
}
var success = await categoryRepository.DeleteAsync(id);
if (success)
{
return new BaseResponse { Success = true };
}
else
{
return BaseResponse.Fail("删除分类失败,分类不存在");
}
}
catch (Exception ex)
{
logger.LogError(ex, "删除分类失败, Id: {Id}", id);
return BaseResponse.Fail($"删除分类失败: {ex.Message}");
}
}
/// <summary>
/// 批量创建分类(用于初始化)
/// </summary>
[HttpPost]
public async Task<BaseResponse<int>> BatchCreateAsync([FromBody] List<CreateCategoryDto> dtoList)
{
try
{
var categories = dtoList.Select(dto => new TransactionCategory
{
Name = dto.Name,
ParentId = dto.ParentId,
Type = dto.Type,
Level = dto.Level,
SortOrder = dto.SortOrder,
Icon = dto.Icon,
Remark = dto.Remark
}).ToList();
var result = await categoryRepository.AddRangeAsync(categories);
if (result)
{
return new BaseResponse<int>
{
Success = true,
Data = categories.Count
};
}
else
{
return BaseResponse<int>.Fail("批量创建分类失败");
}
}
catch (Exception ex)
{
logger.LogError(ex, "批量创建分类失败, Count: {Count}", dtoList.Count);
return BaseResponse<int>.Fail($"批量创建分类失败: {ex.Message}");
}
}
}
/// <summary>
/// 创建分类DTO
/// </summary>
public record CreateCategoryDto(
string Name,
long ParentId,
TransactionType Type,
int Level,
int SortOrder = 0,
string? Icon = null,
string? Remark = null
);
/// <summary>
/// 更新分类DTO
/// </summary>
public record UpdateCategoryDto(
long Id,
string Name,
int SortOrder,
string? Icon,
bool IsEnabled,
string? Remark
);

View File

@@ -0,0 +1,302 @@
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
public class TransactionRecordController(
ITransactionRecordRepository transactionRepository,
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,
SubClassify = dto.SubClassify ?? 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;
transaction.SubClassify = dto.SubClassify ?? 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>
/// 创建交易记录DTO
/// </summary>
public record CreateTransactionDto(
string OccurredAt,
string? Reason,
decimal Amount,
TransactionType Type,
string? Classify,
string? SubClassify
);
/// <summary>
/// 更新交易记录DTO
/// </summary>
public record UpdateTransactionDto(
long Id,
string? Reason,
decimal Amount,
decimal Balance,
TransactionType Type,
string? Classify,
string? SubClassify
);
/// <summary>
/// 日历统计响应DTO
/// </summary>
public record DailyStatisticsDto(
string Date,
int Count,
decimal Amount
);