This commit is contained in:
SunCheng
2026-02-10 17:49:19 +08:00
parent 3e18283e52
commit d052ae5197
104 changed files with 10369 additions and 3000 deletions

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Service\Service.csproj" />
<ProjectReference Include="..\Repository\Repository.csproj" />
<ProjectReference Include="..\Entity\Entity.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Service.AppSettingModel;
namespace Application;
/// <summary>
/// 认证应用服务接口
/// </summary>
public interface IAuthApplication
{
/// <summary>
/// 用户登录
/// </summary>
LoginResponse Login(LoginRequest request);
}
/// <summary>
/// 认证应用服务实现
/// </summary>
public class AuthApplication(
IOptions<AuthSettings> authSettings,
IOptions<JwtSettings> jwtSettings,
ILogger<AuthApplication> logger) : IAuthApplication
{
private readonly AuthSettings _authSettings = authSettings.Value;
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
private readonly ILogger<AuthApplication> _logger = logger;
public LoginResponse Login(LoginRequest request)
{
// 验证密码
if (string.IsNullOrEmpty(request.Password))
{
throw new ValidationException("密码不能为空");
}
if (request.Password != _authSettings.Password)
{
_logger.LogWarning("登录失败: 密码错误");
throw new ValidationException("密码错误");
}
// 生成JWT Token
var token = GenerateJwtToken();
var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours);
_logger.LogInformation("用户登录成功");
return new LoginResponse
{
Token = token,
ExpiresAt = expiresAt
};
}
/// <summary>
/// 生成JWT Token
/// </summary>
private string GenerateJwtToken()
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new Claim("auth", "password-auth")
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,302 @@
using Service.Budget;
namespace Application;
/// <summary>
/// 预算应用服务接口
/// </summary>
public interface IBudgetApplication
{
/// <summary>
/// 获取预算列表
/// </summary>
Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate);
/// <summary>
/// 获取分类统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
/// <summary>
/// 获取未被预算覆盖的分类统计信息
/// </summary>
Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
/// <summary>
/// 获取归档总结
/// </summary>
Task<string?> GetArchiveSummaryAsync(DateTime referenceDate);
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
/// <summary>
/// 删除预算
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 创建预算
/// </summary>
Task<long> CreateAsync(CreateBudgetRequest request);
/// <summary>
/// 更新预算
/// </summary>
Task UpdateAsync(UpdateBudgetRequest request);
}
/// <summary>
/// 预算应用服务实现
/// </summary>
public class BudgetApplication(
IBudgetService budgetService,
IBudgetRepository budgetRepository
) : IBudgetApplication
{
public async Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate)
{
var results = await budgetService.GetListAsync(referenceDate);
// 排序: 刚性支出优先 → 按分类 → 按类型 → 按使用率 → 按名称
return results
.OrderByDescending(b => b.IsMandatoryExpense)
.ThenBy(b => b.Category)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit > 0 ? b.Current / b.Limit : 0)
.ThenBy(b => b.Name)
.Select(MapToResponse)
.ToList();
}
public async Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(
BudgetCategory category,
DateTime referenceDate)
{
var result = await budgetService.GetCategoryStatsAsync(category, referenceDate);
return new BudgetCategoryStatsResponse
{
Month = new BudgetStatsDetail
{
Limit = result.Month.Limit,
Current = result.Month.Current,
Remaining = result.Month.Limit - result.Month.Current,
UsagePercentage = result.Month.Rate
},
Year = new BudgetStatsDetail
{
Limit = result.Year.Limit,
Current = result.Year.Current,
Remaining = result.Year.Limit - result.Year.Current,
UsagePercentage = result.Year.Rate
}
};
}
public async Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(
BudgetCategory category,
DateTime? referenceDate = null)
{
var results = await budgetService.GetUncoveredCategoriesAsync(category, referenceDate);
return results.Select(r => new UncoveredCategoryResponse
{
Category = r.Category,
Amount = r.TotalAmount,
Count = r.TransactionCount
}).ToList();
}
public async Task<string?> GetArchiveSummaryAsync(DateTime referenceDate)
{
return await budgetService.GetArchiveSummaryAsync(referenceDate.Year, referenceDate.Month);
}
public async Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
{
var result = await budgetService.GetSavingsBudgetAsync(year, month, type);
return result == null ? null : MapToResponse(result);
}
public async Task DeleteByIdAsync(long id)
{
var success = await budgetRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除预算失败,记录不存在");
}
}
public async Task<long> CreateAsync(CreateBudgetRequest request)
{
// 业务验证
await ValidateCreateRequestAsync(request);
// 不记额预算的金额强制设为0
var limit = request.NoLimit ? 0 : request.Limit;
var budget = new BudgetRecord
{
Name = request.Name,
Type = request.Type,
Limit = limit,
Category = request.Category,
SelectedCategories = string.Join(",", request.SelectedCategories),
StartDate = request.StartDate ?? DateTime.Now,
NoLimit = request.NoLimit,
IsMandatoryExpense = request.IsMandatoryExpense
};
// 验证分类冲突
await ValidateBudgetCategoriesAsync(budget);
var success = await budgetRepository.AddAsync(budget);
if (!success)
{
throw new BusinessException("创建预算失败");
}
return budget.Id;
}
public async Task UpdateAsync(UpdateBudgetRequest request)
{
var budget = await budgetRepository.GetByIdAsync(request.Id);
if (budget == null)
{
throw new NotFoundException("预算不存在");
}
// 业务验证
await ValidateUpdateRequestAsync(request);
// 不记额预算的金额强制设为0
var limit = request.NoLimit ? 0 : request.Limit;
budget.Name = request.Name;
budget.Type = request.Type;
budget.Limit = limit;
budget.Category = request.Category;
budget.SelectedCategories = string.Join(",", request.SelectedCategories);
budget.NoLimit = request.NoLimit;
budget.IsMandatoryExpense = request.IsMandatoryExpense;
if (request.StartDate.HasValue)
{
budget.StartDate = request.StartDate.Value;
}
// 验证分类冲突
await ValidateBudgetCategoriesAsync(budget);
var success = await budgetRepository.UpdateAsync(budget);
if (!success)
{
throw new BusinessException("更新预算失败");
}
}
#region Private Methods
/// <summary>
/// 映射到响应DTO
/// </summary>
private static BudgetResponse MapToResponse(BudgetResult result)
{
// 解析StartDate字符串为DateTime
DateTime.TryParse(result.StartDate, out var startDate);
return new BudgetResponse
{
Id = result.Id,
Name = result.Name,
Type = result.Type,
Limit = result.Limit,
Current = result.Current,
Category = result.Category,
SelectedCategories = result.SelectedCategories,
StartDate = startDate,
NoLimit = result.NoLimit,
IsMandatoryExpense = result.IsMandatoryExpense,
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0
};
}
/// <summary>
/// 验证创建请求
/// </summary>
private static Task ValidateCreateRequestAsync(CreateBudgetRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ValidationException("预算名称不能为空");
}
if (!request.NoLimit && request.Limit <= 0)
{
throw new ValidationException("预算金额必须大于0");
}
if (request.SelectedCategories.Length == 0)
{
throw new ValidationException("请至少选择一个分类");
}
return Task.CompletedTask;
}
/// <summary>
/// 验证更新请求
/// </summary>
private static Task ValidateUpdateRequestAsync(UpdateBudgetRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ValidationException("预算名称不能为空");
}
if (!request.NoLimit && request.Limit <= 0)
{
throw new ValidationException("预算金额必须大于0");
}
if (request.SelectedCategories.Length == 0)
{
throw new ValidationException("请至少选择一个分类");
}
return Task.CompletedTask;
}
/// <summary>
/// 验证预算分类从Controller迁移的业务逻辑
/// </summary>
private async Task ValidateBudgetCategoriesAsync(BudgetRecord record)
{
// 验证不记额预算必须是年度预算
if (record.NoLimit && record.Type != BudgetPeriodType.Year)
{
throw new ValidationException("不记额预算只能设置为年度预算。");
}
var allBudgets = await budgetRepository.GetAllAsync();
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var budget in allBudgets)
{
var selectedCategories = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (budget.Id != record.Id)
{
if (budget.Category == record.Category &&
recordSelectedCategories.Intersect(selectedCategories).Any())
{
throw new ValidationException($"和 {budget.Name} 存在分类冲突,请调整相关分类。");
}
}
}
}
#endregion
}

View File

@@ -0,0 +1,53 @@
namespace Application;
/// <summary>
/// 配置应用服务接口
/// </summary>
public interface IConfigApplication
{
/// <summary>
/// 获取配置值
/// </summary>
Task<string> GetConfigAsync(string key);
/// <summary>
/// 设置配置值
/// </summary>
Task SetConfigAsync(string key, string value);
}
/// <summary>
/// 配置应用服务实现
/// </summary>
public class ConfigApplication(
IConfigService configService,
ILogger<ConfigApplication> logger
) : IConfigApplication
{
public async Task<string> GetConfigAsync(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ValidationException("配置键不能为空");
}
var value = await configService.GetConfigByKeyAsync<string>(key);
return value ?? string.Empty;
}
public async Task SetConfigAsync(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
{
throw new ValidationException("配置键不能为空");
}
var success = await configService.SetConfigByKeyAsync(key, value);
if (!success)
{
throw new BusinessException($"设置配置 {key} 失败");
}
logger.LogInformation("配置 {Key} 已更新", key);
}
}

View File

@@ -0,0 +1,89 @@
namespace Application.Dto;
/// <summary>
/// 预算响应
/// </summary>
public record BudgetResponse
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; }
public decimal Limit { get; init; }
public decimal Current { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
public decimal UsagePercentage { get; init; }
}
/// <summary>
/// 创建预算请求
/// </summary>
public record CreateBudgetRequest
{
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; } = BudgetPeriodType.Month;
public decimal Limit { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime? StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
}
/// <summary>
/// 更新预算请求
/// </summary>
public record UpdateBudgetRequest
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; } = BudgetPeriodType.Month;
public decimal Limit { get; init; }
public BudgetCategory Category { get; init; }
public string[] SelectedCategories { get; init; } = [];
public DateTime? StartDate { get; init; }
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
}
/// <summary>
/// 分类统计响应
/// </summary>
public record BudgetCategoryStatsResponse
{
public BudgetStatsDetail Month { get; init; } = new();
public BudgetStatsDetail Year { get; init; } = new();
}
/// <summary>
/// 统计详情
/// </summary>
public record BudgetStatsDetail
{
public decimal Limit { get; init; }
public decimal Current { get; init; }
public decimal Remaining { get; init; }
public decimal UsagePercentage { get; init; }
}
/// <summary>
/// 未覆盖分类响应
/// </summary>
public record UncoveredCategoryResponse
{
public string Category { get; init; } = string.Empty;
public decimal Amount { get; init; }
public int Count { get; init; }
}
/// <summary>
/// 更新归档总结请求
/// </summary>
public record UpdateArchiveSummaryRequest
{
public DateTime ReferenceDate { get; init; }
public string? Summary { get; init; }
}

View File

@@ -0,0 +1,49 @@
namespace Application.Dto.Category;
/// <summary>
/// 分类响应
/// </summary>
public record CategoryResponse
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public TransactionType Type { get; init; }
public string? Icon { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
}
/// <summary>
/// 创建分类请求
/// </summary>
public record CreateCategoryRequest
{
public string Name { get; init; } = string.Empty;
public TransactionType Type { get; init; }
}
/// <summary>
/// 更新分类请求
/// </summary>
public record UpdateCategoryRequest
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
}
/// <summary>
/// 生成图标请求
/// </summary>
public record GenerateIconRequest
{
public long CategoryId { get; init; }
}
/// <summary>
/// 更新选中图标请求
/// </summary>
public record UpdateSelectedIconRequest
{
public long CategoryId { get; init; }
public int SelectedIndex { get; init; }
}

View File

@@ -0,0 +1,28 @@
namespace Application.Dto;
/// <summary>
/// 获取配置请求
/// </summary>
public record GetConfigRequest
{
/// <summary>
/// 配置键
/// </summary>
public string Key { get; init; } = string.Empty;
}
/// <summary>
/// 设置配置请求
/// </summary>
public record SetConfigRequest
{
/// <summary>
/// 配置键
/// </summary>
public string Key { get; init; } = string.Empty;
/// <summary>
/// 配置值
/// </summary>
public string Value { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,38 @@
namespace Application.Dto.Email;
/// <summary>
/// 邮件消息响应
/// </summary>
public record EmailMessageResponse
{
public long Id { get; init; }
public string Subject { get; init; } = string.Empty;
public string From { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public string HtmlBody { get; init; } = string.Empty;
public DateTime ReceivedDate { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
public int TransactionCount { get; init; }
public string ToName { get; init; } = string.Empty;
}
/// <summary>
/// 邮件查询请求
/// </summary>
public record EmailQueryRequest
{
public DateTime? LastReceivedDate { get; init; }
public long? LastId { get; init; }
}
/// <summary>
/// 邮件分页结果
/// </summary>
public record EmailPagedResult
{
public EmailMessageResponse[] Data { get; init; } = [];
public int Total { get; init; }
public long? LastId { get; init; }
public DateTime? LastTime { get; init; }
}

View File

@@ -0,0 +1,38 @@
namespace Application.Dto;
/// <summary>
/// 导入请求
/// </summary>
public record ImportRequest
{
/// <summary>
/// 文件流
/// </summary>
public required Stream FileStream { get; init; }
/// <summary>
/// 文件扩展名(.csv, .xlsx, .xls
/// </summary>
public required string FileExtension { get; init; }
/// <summary>
/// 文件名
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件大小(字节)
/// </summary>
public long FileSize { get; init; }
}
/// <summary>
/// 导入响应
/// </summary>
public record ImportResponse
{
/// <summary>
/// 导入结果消息
/// </summary>
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
namespace Application.Dto;
/// <summary>
/// 登录请求
/// </summary>
public record LoginRequest
{
/// <summary>
/// 密码
/// </summary>
public string Password { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
namespace Application.Dto;
/// <summary>
/// 登录响应
/// </summary>
public record LoginResponse
{
/// <summary>
/// JWT Token
/// </summary>
public string Token { get; init; } = string.Empty;
/// <summary>
/// Token过期时间UTC
/// </summary>
public DateTime ExpiresAt { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace Application.Dto.Message;
/// <summary>
/// 消息记录响应
/// </summary>
public record MessageRecordResponse
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public bool IsRead { get; init; }
public DateTime CreateTime { get; init; }
}
/// <summary>
/// 消息分页结果
/// </summary>
public record MessagePagedResult
{
public MessageRecordResponse[] Data { get; init; } = [];
public int Total { get; init; }
}

View File

@@ -0,0 +1,56 @@
namespace Application.Dto.Periodic;
/// <summary>
/// 周期性账单响应
/// </summary>
public record PeriodicResponse
{
public long Id { get; init; }
public PeriodicType PeriodicType { get; init; }
public string PeriodicConfig { get; init; } = string.Empty;
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public bool IsEnabled { get; init; }
public DateTime? NextExecuteTime { get; init; }
public DateTime CreateTime { get; init; }
public DateTime? UpdateTime { get; init; }
}
/// <summary>
/// 创建周期性账单请求
/// </summary>
public record CreatePeriodicRequest
{
public PeriodicType PeriodicType { get; init; }
public string? PeriodicConfig { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// 更新周期性账单请求
/// </summary>
public record UpdatePeriodicRequest
{
public long Id { get; init; }
public PeriodicType PeriodicType { get; init; }
public string? PeriodicConfig { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? Reason { get; init; }
public bool IsEnabled { get; init; }
}
/// <summary>
/// 周期性账单分页结果
/// </summary>
public record PeriodicPagedResult
{
public PeriodicResponse[] Data { get; init; } = [];
public int Total { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace Application.Dto.Statistics;
/// <summary>
/// 余额统计DTO
/// </summary>
public record BalanceStatisticsDto(
int Day,
decimal CumulativeBalance
);
/// <summary>
/// 每日统计DTO
/// </summary>
public record DailyStatisticsDto(
int Day,
int Count,
decimal Expense,
decimal Income,
decimal Saving
);

View File

@@ -0,0 +1,106 @@
namespace Application.Dto.Transaction;
/// <summary>
/// 交易响应
/// </summary>
public record TransactionResponse
{
public long Id { get; init; }
public DateTime OccurredAt { get; init; }
public string Reason { get; init; } = string.Empty;
public decimal Amount { get; init; }
public decimal Balance { get; init; }
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
public string? UnconfirmedClassify { get; init; }
public TransactionType? UnconfirmedType { get; init; }
}
/// <summary>
/// 创建交易请求
/// </summary>
public record CreateTransactionRequest
{
public string OccurredAt { get; init; } = string.Empty;
public string? Reason { get; init; }
public decimal Amount { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
}
/// <summary>
/// 更新交易请求
/// </summary>
public record UpdateTransactionRequest
{
public long Id { get; init; }
public string? Reason { get; init; }
public decimal Amount { get; init; }
public decimal Balance { get; init; }
public TransactionType Type { get; init; }
public string? Classify { get; init; }
public string? OccurredAt { get; init; }
}
/// <summary>
/// 交易查询请求
/// </summary>
public record TransactionQueryRequest
{
public int PageIndex { get; init; } = 1;
public int PageSize { get; init; } = 20;
public string? SearchKeyword { get; init; }
public string? Classify { get; init; }
public int? Type { get; init; }
public int? Year { get; init; }
public int? Month { get; init; }
public DateTime? StartDate { get; init; }
public DateTime? EndDate { get; init; }
public string? Reason { get; init; }
public bool SortByAmount { get; init; }
}
/// <summary>
/// 分页结果
/// </summary>
public record PagedResult<T>
{
public T[] Data { get; init; } = [];
public int Total { get; init; }
}
/// <summary>
/// 批量更新分类项
/// </summary>
public record BatchUpdateClassifyItem
{
public long Id { get; init; }
public string? Classify { get; init; }
public TransactionType? Type { get; init; }
}
/// <summary>
/// 按摘要批量更新请求
/// </summary>
public record BatchUpdateByReasonRequest
{
public string Reason { get; init; } = string.Empty;
public TransactionType Type { get; init; }
public string Classify { get; init; } = string.Empty;
}
/// <summary>
/// 一句话录账解析请求
/// </summary>
public record ParseOneLineRequest
{
public string Text { get; init; } = string.Empty;
}
/// <summary>
/// 确认所有未确认记录请求
/// </summary>
public record ConfirmAllUnconfirmedRequest
{
public long[] Ids { get; init; } = [];
}

View File

@@ -0,0 +1,131 @@
using Application.Dto.Email;
using Service.EmailServices;
namespace Application;
/// <summary>
/// 邮件消息应用服务接口
/// </summary>
public interface IEmailMessageApplication
{
/// <summary>
/// 获取邮件列表(分页)
/// </summary>
Task<EmailPagedResult> GetListAsync(EmailQueryRequest request);
/// <summary>
/// 根据ID获取邮件详情
/// </summary>
Task<EmailMessageResponse> GetByIdAsync(long id);
/// <summary>
/// 删除邮件
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 重新分析邮件并刷新交易记录
/// </summary>
Task RefreshTransactionRecordsAsync(long id);
/// <summary>
/// 立即同步邮件
/// </summary>
Task SyncEmailsAsync();
}
/// <summary>
/// 邮件消息应用服务实现
/// </summary>
public class EmailMessageApplication(
IEmailMessageRepository emailRepository,
ITransactionRecordRepository transactionRepository,
IEmailHandleService emailHandleService,
IEmailSyncService emailSyncService,
ILogger<EmailMessageApplication> logger
) : IEmailMessageApplication
{
public async Task<EmailPagedResult> GetListAsync(EmailQueryRequest request)
{
var (list, lastTime, lastId) = await emailRepository.GetPagedListAsync(
request.LastReceivedDate,
request.LastId);
var total = await emailRepository.GetTotalCountAsync();
// 为每个邮件获取账单数量
var emailResponses = new List<EmailMessageResponse>();
foreach (var email in list)
{
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(email.Id);
emailResponses.Add(MapToResponse(email, transactionCount));
}
return new EmailPagedResult
{
Data = emailResponses.ToArray(),
Total = (int)total,
LastId = lastId,
LastTime = lastTime
};
}
public async Task<EmailMessageResponse> GetByIdAsync(long id)
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
throw new NotFoundException("邮件不存在");
}
// 获取账单数量
var transactionCount = await transactionRepository.GetCountByEmailIdAsync(id);
return MapToResponse(email, transactionCount);
}
public async Task DeleteByIdAsync(long id)
{
var success = await emailRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除邮件失败,邮件不存在");
}
}
public async Task RefreshTransactionRecordsAsync(long id)
{
var email = await emailRepository.GetByIdAsync(id);
if (email == null)
{
throw new NotFoundException("邮件不存在");
}
var success = await emailHandleService.RefreshTransactionRecordsAsync(id);
if (!success)
{
throw new BusinessException("重新分析失败");
}
}
public async Task SyncEmailsAsync()
{
await emailSyncService.SyncEmailsAsync();
}
private static EmailMessageResponse MapToResponse(EmailMessage email, int transactionCount)
{
return new EmailMessageResponse
{
Id = email.Id,
Subject = email.Subject,
From = email.From,
Body = email.Body,
HtmlBody = email.HtmlBody,
ReceivedDate = email.ReceivedDate,
CreateTime = email.CreateTime,
UpdateTime = email.UpdateTime,
TransactionCount = transactionCount,
ToName = email.To.Split('<').FirstOrDefault()?.Trim() ?? "未知"
};
}
}

View File

@@ -0,0 +1,16 @@
namespace Application.Exceptions;
/// <summary>
/// 应用层异常基类
/// </summary>
public class ApplicationException : Exception
{
public ApplicationException(string message) : base(message)
{
}
public ApplicationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,19 @@
namespace Application.Exceptions;
/// <summary>
/// 业务逻辑异常对应HTTP 500 Internal Server Error
/// </summary>
/// <remarks>
/// 用于业务操作失败、数据状态不一致等场景
/// </remarks>
public class BusinessException : ApplicationException
{
public BusinessException(string message) : base(message)
{
}
public BusinessException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,11 @@
namespace Application.Exceptions;
/// <summary>
/// 资源未找到异常对应HTTP 404 Not Found
/// </summary>
/// <remarks>
/// 用于查询的资源不存在等场景
/// </remarks>
public class NotFoundException(string message) : ApplicationException(message)
{
}

View File

@@ -0,0 +1,11 @@
namespace Application.Exceptions;
/// <summary>
/// 业务验证异常对应HTTP 400 Bad Request
/// </summary>
/// <remarks>
/// 用于参数验证失败、业务规则不满足等场景
/// </remarks>
public class ValidationException(string message) : ApplicationException(message)
{
}

View File

@@ -0,0 +1,36 @@
namespace Application.Extensions;
/// <summary>
/// Application层服务注册扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册所有Application层服务
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// 获取Application层程序集
var assembly = typeof(ServiceCollectionExtensions).Assembly;
// 自动注册所有以"Application"结尾的类
// 匹配接口规则: IXxxApplication -> XxxApplication
var applicationTypes = assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && t.Name.EndsWith("Application"))
.ToList();
foreach (var implementationType in applicationTypes)
{
// 查找对应的接口 IXxxApplication
var interfaceType = implementationType.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{implementationType.Name}");
if (interfaceType != null)
{
services.AddScoped(interfaceType, implementationType);
}
}
return services;
}
}

View File

@@ -0,0 +1,13 @@
// 全局引用 - Application层
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.DependencyInjection;
global using System.Text;
// 项目引用
global using Entity;
global using Repository;
global using Service;
// Application层内部引用
global using Application.Exceptions;
global using Application.Dto;

View File

@@ -0,0 +1,112 @@
namespace Application;
/// <summary>
/// 导入类型
/// </summary>
public enum ImportType
{
/// <summary>
/// 支付宝
/// </summary>
Alipay,
/// <summary>
/// 微信
/// </summary>
WeChat
}
/// <summary>
/// 导入应用服务接口
/// </summary>
public interface IImportApplication
{
/// <summary>
/// 导入支付宝账单
/// </summary>
Task<ImportResponse> ImportAlipayAsync(ImportRequest request);
/// <summary>
/// 导入微信账单
/// </summary>
Task<ImportResponse> ImportWeChatAsync(ImportRequest request);
}
/// <summary>
/// 导入应用服务实现
/// </summary>
public class ImportApplication(
IImportService importService,
ILogger<ImportApplication> logger
) : IImportApplication
{
private static readonly string[] AllowedExtensions = { ".csv", ".xlsx", ".xls" };
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
public async Task<ImportResponse> ImportAlipayAsync(ImportRequest request)
{
ValidateRequest(request);
var (ok, message) = await importService.ImportAlipayAsync(
(MemoryStream)request.FileStream,
request.FileExtension
);
if (!ok)
{
throw new BusinessException(message);
}
logger.LogInformation("支付宝账单导入成功: {Message}", message);
return new ImportResponse { Message = message };
}
public async Task<ImportResponse> ImportWeChatAsync(ImportRequest request)
{
ValidateRequest(request);
var (ok, message) = await importService.ImportWeChatAsync(
(MemoryStream)request.FileStream,
request.FileExtension
);
if (!ok)
{
throw new BusinessException(message);
}
logger.LogInformation("微信账单导入成功: {Message}", message);
return new ImportResponse { Message = message };
}
/// <summary>
/// 验证导入请求
/// </summary>
private static void ValidateRequest(ImportRequest request)
{
// 验证文件流
if (request.FileStream == null || request.FileStream.Length == 0)
{
throw new ValidationException("请选择要上传的文件");
}
// 验证文件扩展名
if (string.IsNullOrWhiteSpace(request.FileExtension))
{
throw new ValidationException("文件扩展名不能为空");
}
var extension = request.FileExtension.ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
{
throw new ValidationException("只支持 CSV 或 Excel 文件格式");
}
// 验证文件大小
if (request.FileSize > MaxFileSize)
{
throw new ValidationException("文件大小不能超过 10MB");
}
}
}

View File

@@ -0,0 +1,107 @@
using Quartz;
using Quartz.Impl.Matchers;
namespace Application;
/// <summary>
/// 任务状态
/// </summary>
public record JobStatus
{
public string Name { get; init; } = string.Empty;
public string JobDescription { get; init; } = string.Empty;
public string TriggerDescription { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string NextRunTime { get; init; } = string.Empty;
}
/// <summary>
/// 任务应用服务接口
/// </summary>
public interface IJobApplication
{
Task<List<JobStatus>> GetJobsAsync();
Task<bool> ExecuteAsync(string jobName);
Task<bool> PauseAsync(string jobName);
Task<bool> ResumeAsync(string jobName);
}
/// <summary>
/// 任务应用服务实现
/// </summary>
public class JobApplication(
ISchedulerFactory schedulerFactory,
ILogger<JobApplication> logger
) : IJobApplication
{
public async Task<List<JobStatus>> GetJobsAsync()
{
var scheduler = await schedulerFactory.GetScheduler();
var jobKeys = await scheduler.GetJobKeys(GroupMatcher<JobKey>.AnyGroup());
var jobStatuses = new List<JobStatus>();
foreach (var jobKey in jobKeys)
{
var jobDetail = await scheduler.GetJobDetail(jobKey);
var triggers = await scheduler.GetTriggersOfJob(jobKey);
var trigger = triggers.FirstOrDefault();
var status = "Unknown";
DateTime? nextFireTime = null;
if (trigger != null)
{
var triggerState = await scheduler.GetTriggerState(trigger.Key);
status = triggerState.ToString();
nextFireTime = trigger.GetNextFireTimeUtc()?.ToLocalTime().DateTime;
}
jobStatuses.Add(new JobStatus
{
Name = jobKey.Name,
JobDescription = jobDetail?.Description ?? jobKey.Name,
TriggerDescription = trigger?.Description ?? string.Empty,
Status = status,
NextRunTime = nextFireTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"
});
}
return jobStatuses;
}
public async Task<bool> ExecuteAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.TriggerJob(new JobKey(jobName));
return true;
}
public async Task<bool> PauseAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.PauseJob(new JobKey(jobName));
return true;
}
public async Task<bool> ResumeAsync(string jobName)
{
if (string.IsNullOrWhiteSpace(jobName))
{
throw new ValidationException("任务名称不能为空");
}
var scheduler = await schedulerFactory.GetScheduler();
await scheduler.ResumeJob(new JobKey(jobName));
return true;
}
}

View File

@@ -0,0 +1,97 @@
using Application.Dto.Message;
using Service.Message;
namespace Application;
/// <summary>
/// 消息记录应用服务接口
/// </summary>
public interface IMessageRecordApplication
{
/// <summary>
/// 获取消息列表(分页)
/// </summary>
Task<MessagePagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20);
/// <summary>
/// 获取未读消息数量
/// </summary>
Task<long> GetUnreadCountAsync();
/// <summary>
/// 标记为已读
/// </summary>
Task<bool> MarkAsReadAsync(long id);
/// <summary>
/// 全部标记为已读
/// </summary>
Task<bool> MarkAllAsReadAsync();
/// <summary>
/// 删除消息
/// </summary>
Task<bool> DeleteAsync(long id);
/// <summary>
/// 新增消息
/// </summary>
Task<bool> AddAsync(MessageRecord message);
}
/// <summary>
/// 消息记录应用服务实现
/// </summary>
public class MessageRecordApplication(
IMessageService messageService,
ILogger<MessageRecordApplication> logger
) : IMessageRecordApplication
{
public async Task<MessagePagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20)
{
var (list, total) = await messageService.GetPagedListAsync(pageIndex, pageSize);
return new MessagePagedResult
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<long> GetUnreadCountAsync()
{
return await messageService.GetUnreadCountAsync();
}
public async Task<bool> MarkAsReadAsync(long id)
{
return await messageService.MarkAsReadAsync(id);
}
public async Task<bool> MarkAllAsReadAsync()
{
return await messageService.MarkAllAsReadAsync();
}
public async Task<bool> DeleteAsync(long id)
{
return await messageService.DeleteAsync(id);
}
public async Task<bool> AddAsync(MessageRecord message)
{
return await messageService.AddAsync(message);
}
private static MessageRecordResponse MapToResponse(MessageRecord record)
{
return new MessageRecordResponse
{
Id = record.Id,
Title = record.Title,
Content = record.Content,
IsRead = record.IsRead,
CreateTime = record.CreateTime
};
}
}

View File

@@ -0,0 +1,37 @@
using Service.Message;
namespace Application;
/// <summary>
/// 通知应用服务接口
/// </summary>
public interface INotificationApplication
{
Task<string> GetVapidPublicKeyAsync();
Task SubscribeAsync(PushSubscription subscription);
Task SendNotificationAsync(string message);
}
/// <summary>
/// 通知应用服务实现
/// </summary>
public class NotificationApplication(
INotificationService notificationService,
ILogger<NotificationApplication> logger
) : INotificationApplication
{
public async Task<string> GetVapidPublicKeyAsync()
{
return await notificationService.GetVapidPublicKeyAsync();
}
public async Task SubscribeAsync(PushSubscription subscription)
{
await notificationService.SubscribeAsync(subscription);
}
public async Task SendNotificationAsync(string message)
{
await notificationService.SendNotificationAsync(message);
}
}

View File

@@ -0,0 +1,388 @@
using Application.Dto.Transaction;
using Service.AI;
namespace Application;
/// <summary>
/// 交易应用服务接口
/// </summary>
public interface ITransactionApplication
{
/// <summary>
/// 获取交易记录列表(分页)
/// </summary>
Task<PagedResult<TransactionResponse>> GetListAsync(TransactionQueryRequest request);
/// <summary>
/// 根据ID获取交易记录详情
/// </summary>
Task<TransactionResponse> GetByIdAsync(long id);
/// <summary>
/// 创建交易记录
/// </summary>
Task CreateAsync(CreateTransactionRequest request);
/// <summary>
/// 更新交易记录
/// </summary>
Task UpdateAsync(UpdateTransactionRequest request);
/// <summary>
/// 删除交易记录
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 根据邮件ID获取交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId);
/// <summary>
/// 根据日期获取交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetByDateAsync(DateTime date);
/// <summary>
/// 获取未确认的交易记录列表
/// </summary>
Task<List<TransactionResponse>> GetUnconfirmedListAsync();
/// <summary>
/// 获取未确认的交易记录数量
/// </summary>
Task<int> GetUnconfirmedCountAsync();
/// <summary>
/// 获取未分类的账单数量
/// </summary>
Task<int> GetUnclassifiedCountAsync();
/// <summary>
/// 获取未分类的账单列表
/// </summary>
Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize);
/// <summary>
/// 确认所有未确认的记录
/// </summary>
Task<int> ConfirmAllUnconfirmedAsync(long[] ids);
/// <summary>
/// 智能分类AI分类支持回调
/// </summary>
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> onChunk);
/// <summary>
/// 一句话录账解析
/// </summary>
Task<TransactionParseResult?> ParseOneLineAsync(string text);
/// <summary>
/// 账单分析AI分析支持回调
/// </summary>
Task AnalyzeBillAsync(string userInput, Action<string> onChunk);
/// <summary>
/// 批量更新分类
/// </summary>
Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items);
/// <summary>
/// 按摘要批量更新分类
/// </summary>
Task<int> BatchUpdateByReasonAsync(BatchUpdateByReasonRequest request);
}
/// <summary>
/// 交易应用服务实现
/// </summary>
public class TransactionApplication(
ITransactionRecordRepository transactionRepository,
ISmartHandleService smartHandleService,
ILogger<TransactionApplication> logger
) : ITransactionApplication
{
public async Task<PagedResult<TransactionResponse>> GetListAsync(TransactionQueryRequest request)
{
var classifies = string.IsNullOrWhiteSpace(request.Classify)
? null
: request.Classify.Split(',', StringSplitOptions.RemoveEmptyEntries);
TransactionType? transactionType = request.Type.HasValue ? (TransactionType)request.Type.Value : null;
var list = await transactionRepository.QueryAsync(
year: request.Year,
month: request.Month,
startDate: request.StartDate,
endDate: request.EndDate,
type: transactionType,
classifies: classifies,
searchKeyword: request.SearchKeyword,
reason: request.Reason,
pageIndex: request.PageIndex,
pageSize: request.PageSize,
sortByAmount: request.SortByAmount);
var total = await transactionRepository.CountAsync(
year: request.Year,
month: request.Month,
startDate: request.StartDate,
endDate: request.EndDate,
type: transactionType,
classifies: classifies,
searchKeyword: request.SearchKeyword,
reason: request.Reason);
return new PagedResult<TransactionResponse>
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<TransactionResponse> GetByIdAsync(long id)
{
var transaction = await transactionRepository.GetByIdAsync(id);
if (transaction == null)
{
throw new NotFoundException("交易记录不存在");
}
return MapToResponse(transaction);
}
public async Task CreateAsync(CreateTransactionRequest request)
{
// 解析日期字符串
if (!DateTime.TryParse(request.OccurredAt, out var occurredAt))
{
throw new ValidationException("交易时间格式不正确");
}
var transaction = new TransactionRecord
{
OccurredAt = occurredAt,
Reason = request.Reason ?? string.Empty,
Amount = request.Amount,
Type = request.Type,
Classify = request.Classify ?? string.Empty,
ImportFrom = "手动录入",
ImportNo = Guid.NewGuid().ToString("N"),
Card = "手动",
EmailMessageId = 0
};
var result = await transactionRepository.AddAsync(transaction);
if (!result)
{
throw new BusinessException("创建交易记录失败");
}
}
public async Task UpdateAsync(UpdateTransactionRequest request)
{
var transaction = await transactionRepository.GetByIdAsync(request.Id);
if (transaction == null)
{
throw new NotFoundException("交易记录不存在");
}
// 更新可编辑字段
transaction.Reason = request.Reason ?? string.Empty;
transaction.Amount = request.Amount;
transaction.Balance = request.Balance;
transaction.Type = request.Type;
transaction.Classify = request.Classify ?? string.Empty;
// 更新交易时间
if (!string.IsNullOrEmpty(request.OccurredAt) && DateTime.TryParse(request.OccurredAt, out var occurredAt))
{
transaction.OccurredAt = occurredAt;
}
// 清除待确认状态
transaction.UnconfirmedClassify = null;
transaction.UnconfirmedType = null;
var success = await transactionRepository.UpdateAsync(transaction);
if (!success)
{
throw new BusinessException("更新交易记录失败");
}
}
public async Task DeleteByIdAsync(long id)
{
var success = await transactionRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除交易记录失败,记录不存在");
}
}
public async Task<List<TransactionResponse>> GetByEmailIdAsync(long emailId)
{
var transactions = await transactionRepository.GetByEmailIdAsync(emailId);
return transactions.Select(MapToResponse).ToList();
}
public async Task<List<TransactionResponse>> GetByDateAsync(DateTime date)
{
// 获取当天的开始和结束时间
var startDate = date.Date;
var endDate = startDate.AddDays(1);
var records = await transactionRepository.QueryAsync(startDate: startDate, endDate: endDate);
return records.Select(MapToResponse).ToList();
}
public async Task<List<TransactionResponse>> GetUnconfirmedListAsync()
{
var records = await transactionRepository.GetUnconfirmedRecordsAsync();
return records.Select(MapToResponse).ToList();
}
public async Task<int> GetUnconfirmedCountAsync()
{
var records = await transactionRepository.GetUnconfirmedRecordsAsync();
return records.Count;
}
public async Task<int> GetUnclassifiedCountAsync()
{
return (int)await transactionRepository.CountAsync();
}
public async Task<List<TransactionResponse>> GetUnclassifiedAsync(int pageSize)
{
var records = await transactionRepository.GetUnclassifiedAsync(pageSize);
return records.Select(MapToResponse).ToList();
}
public async Task<int> ConfirmAllUnconfirmedAsync(long[] ids)
{
if (ids == null || ids.Length == 0)
{
throw new ValidationException("请提供要确认的交易ID列表");
}
return await transactionRepository.ConfirmAllUnconfirmedAsync(ids);
}
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> onChunk)
{
// 验证
if (transactionIds == null || transactionIds.Length == 0)
{
throw new ValidationException("请提供要分类的账单ID");
}
// 调用Service进行智能分类
await smartHandleService.SmartClassifyAsync(transactionIds, onChunk);
}
public async Task<TransactionParseResult?> ParseOneLineAsync(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
throw new ValidationException("解析文本不能为空");
}
var result = await smartHandleService.ParseOneLineBillAsync(text);
if (result == null)
{
throw new BusinessException("AI解析失败");
}
return result;
}
public async Task AnalyzeBillAsync(string userInput, Action<string> onChunk)
{
if (string.IsNullOrWhiteSpace(userInput))
{
throw new ValidationException("请输入分析内容");
}
await smartHandleService.AnalyzeBillAsync(userInput, onChunk);
}
public async Task<int> BatchUpdateClassifyAsync(List<BatchUpdateClassifyItem> items)
{
if (items == null || items.Count == 0)
{
throw new ValidationException("请提供要更新的记录");
}
var successCount = 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 (item.Type.HasValue && record.Type == item.Type.Value)
{
record.UnconfirmedType = null;
}
var success = await transactionRepository.UpdateAsync(record);
if (success)
{
successCount++;
}
}
}
return successCount;
}
public async Task<int> BatchUpdateByReasonAsync(BatchUpdateByReasonRequest request)
{
if (string.IsNullOrWhiteSpace(request.Reason))
{
throw new ValidationException("摘要不能为空");
}
if (string.IsNullOrWhiteSpace(request.Classify))
{
throw new ValidationException("分类不能为空");
}
return await transactionRepository.BatchUpdateByReasonAsync(
request.Reason,
request.Type,
request.Classify);
}
private static TransactionResponse MapToResponse(TransactionRecord record)
{
return new TransactionResponse
{
Id = record.Id,
OccurredAt = record.OccurredAt,
Reason = record.Reason,
Amount = record.Amount,
Balance = record.Balance,
Type = record.Type,
Classify = record.Classify,
UnconfirmedClassify = record.UnconfirmedClassify,
UnconfirmedType = record.UnconfirmedType
};
}
}

View File

@@ -0,0 +1,231 @@
using Application.Dto.Category;
using Service.AI;
using System.Text.Json;
namespace Application;
/// <summary>
/// 交易分类应用服务接口
/// </summary>
public interface ITransactionCategoryApplication
{
Task<List<CategoryResponse>> GetListAsync(TransactionType? type = null);
Task<CategoryResponse> GetByIdAsync(long id);
Task<long> CreateAsync(CreateCategoryRequest request);
Task UpdateAsync(UpdateCategoryRequest request);
Task DeleteAsync(long id);
Task<int> BatchCreateAsync(List<CreateCategoryRequest> requests);
Task<string> GenerateIconAsync(GenerateIconRequest request);
Task UpdateSelectedIconAsync(UpdateSelectedIconRequest request);
}
/// <summary>
/// 交易分类应用服务实现
/// </summary>
public class TransactionCategoryApplication(
ITransactionCategoryRepository categoryRepository,
ITransactionRecordRepository transactionRepository,
IBudgetRepository budgetRepository,
ISmartHandleService smartHandleService,
ILogger<TransactionCategoryApplication> logger
) : ITransactionCategoryApplication
{
public async Task<List<CategoryResponse>> GetListAsync(TransactionType? type = null)
{
List<TransactionCategory> categories;
if (type.HasValue)
{
categories = await categoryRepository.GetCategoriesByTypeAsync(type.Value);
}
else
{
categories = (await categoryRepository.GetAllAsync()).ToList();
}
return categories.Select(MapToResponse).ToList();
}
public async Task<CategoryResponse> GetByIdAsync(long id)
{
var category = await categoryRepository.GetByIdAsync(id);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
return MapToResponse(category);
}
public async Task<long> CreateAsync(CreateCategoryRequest request)
{
// 检查同名分类
var existing = await categoryRepository.GetByNameAndTypeAsync(request.Name, request.Type);
if (existing != null)
{
throw new ValidationException("已存在相同名称的分类");
}
var category = new TransactionCategory
{
Name = request.Name,
Type = request.Type
};
var result = await categoryRepository.AddAsync(category);
if (!result)
{
throw new BusinessException("创建分类失败");
}
return category.Id;
}
public async Task UpdateAsync(UpdateCategoryRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.Id);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 如果修改了名称,检查同名
if (category.Name != request.Name)
{
var existing = await categoryRepository.GetByNameAndTypeAsync(request.Name, category.Type);
if (existing != null && existing.Id != request.Id)
{
throw new ValidationException("已存在相同名称的分类");
}
// 同步更新交易记录中的分类名称
await transactionRepository.UpdateCategoryNameAsync(category.Name, request.Name, category.Type);
await budgetRepository.UpdateBudgetCategoryNameAsync(category.Name, request.Name, category.Type);
}
category.Name = request.Name;
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新分类失败");
}
}
public async Task DeleteAsync(long id)
{
// 检查是否被使用
var inUse = await categoryRepository.IsCategoryInUseAsync(id);
if (inUse)
{
throw new ValidationException("该分类已被使用,无法删除");
}
var success = await categoryRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除分类失败,分类不存在");
}
}
public async Task<int> BatchCreateAsync(List<CreateCategoryRequest> requests)
{
var categories = requests.Select(r => new TransactionCategory
{
Name = r.Name,
Type = r.Type
}).ToList();
var result = await categoryRepository.AddRangeAsync(categories);
if (!result)
{
throw new BusinessException("批量创建分类失败");
}
return categories.Count;
}
public async Task<string> GenerateIconAsync(GenerateIconRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.CategoryId);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 使用 SmartHandleService 统一封装的图标生成方法
var svg = await smartHandleService.GenerateSingleCategoryIconAsync(category.Name, category.Type);
if (string.IsNullOrWhiteSpace(svg))
{
throw new BusinessException("AI生成图标失败");
}
// 解析现有图标数组
var icons = string.IsNullOrWhiteSpace(category.Icon)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(category.Icon) ?? new List<string>();
// 添加新图标
icons.Add(svg);
// 更新数据库
category.Icon = JsonSerializer.Serialize(icons);
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新分类图标失败");
}
return svg;
}
public async Task UpdateSelectedIconAsync(UpdateSelectedIconRequest request)
{
var category = await categoryRepository.GetByIdAsync(request.CategoryId);
if (category == null)
{
throw new NotFoundException("分类不存在");
}
// 验证索引有效性
if (string.IsNullOrWhiteSpace(category.Icon))
{
throw new ValidationException("该分类没有可用图标");
}
var icons = JsonSerializer.Deserialize<List<string>>(category.Icon);
if (icons == null || request.SelectedIndex < 0 || request.SelectedIndex >= icons.Count)
{
throw new ValidationException("无效的图标索引");
}
// 将选中的图标移到数组第一位
var selectedIcon = icons[request.SelectedIndex];
icons.RemoveAt(request.SelectedIndex);
icons.Insert(0, selectedIcon);
category.Icon = JsonSerializer.Serialize(icons);
category.UpdateTime = DateTime.Now;
var success = await categoryRepository.UpdateAsync(category);
if (!success)
{
throw new BusinessException("更新图标失败");
}
}
private static CategoryResponse MapToResponse(TransactionCategory category)
{
return new CategoryResponse
{
Id = category.Id,
Name = category.Name,
Type = category.Type,
Icon = category.Icon,
CreateTime = category.CreateTime,
UpdateTime = category.UpdateTime
};
}
}

View File

@@ -0,0 +1,170 @@
using Application.Dto.Periodic;
using Service.Transaction;
namespace Application;
/// <summary>
/// 周期性账单应用服务接口
/// </summary>
public interface ITransactionPeriodicApplication
{
/// <summary>
/// 获取周期性账单列表(分页)
/// </summary>
Task<PeriodicPagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20, string? searchKeyword = null);
/// <summary>
/// 根据ID获取周期性账单详情
/// </summary>
Task<PeriodicResponse> GetByIdAsync(long id);
/// <summary>
/// 创建周期性账单
/// </summary>
Task<PeriodicResponse> CreateAsync(CreatePeriodicRequest request);
/// <summary>
/// 更新周期性账单
/// </summary>
Task UpdateAsync(UpdatePeriodicRequest request);
/// <summary>
/// 删除周期性账单
/// </summary>
Task DeleteByIdAsync(long id);
/// <summary>
/// 启用/禁用周期性账单
/// </summary>
Task ToggleEnabledAsync(long id, bool enabled);
}
/// <summary>
/// 周期性账单应用服务实现
/// </summary>
public class TransactionPeriodicApplication(
ITransactionPeriodicRepository periodicRepository,
ITransactionPeriodicService periodicService,
ILogger<TransactionPeriodicApplication> logger
) : ITransactionPeriodicApplication
{
public async Task<PeriodicPagedResult> GetListAsync(int pageIndex = 1, int pageSize = 20, string? searchKeyword = null)
{
var list = await periodicRepository.GetPagedListAsync(pageIndex, pageSize, searchKeyword);
var total = await periodicRepository.GetTotalCountAsync(searchKeyword);
return new PeriodicPagedResult
{
Data = list.Select(MapToResponse).ToArray(),
Total = (int)total
};
}
public async Task<PeriodicResponse> GetByIdAsync(long id)
{
var periodic = await periodicRepository.GetByIdAsync(id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
return MapToResponse(periodic);
}
public async Task<PeriodicResponse> CreateAsync(CreatePeriodicRequest request)
{
var periodic = new TransactionPeriodic
{
PeriodicType = request.PeriodicType,
PeriodicConfig = request.PeriodicConfig ?? string.Empty,
Amount = request.Amount,
Type = request.Type,
Classify = request.Classify ?? string.Empty,
Reason = request.Reason ?? string.Empty,
IsEnabled = true
};
// 计算下次执行时间
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
var success = await periodicRepository.AddAsync(periodic);
if (!success)
{
throw new BusinessException("创建周期性账单失败");
}
return MapToResponse(periodic);
}
public async Task UpdateAsync(UpdatePeriodicRequest request)
{
var periodic = await periodicRepository.GetByIdAsync(request.Id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
periodic.PeriodicType = request.PeriodicType;
periodic.PeriodicConfig = request.PeriodicConfig ?? string.Empty;
periodic.Amount = request.Amount;
periodic.Type = request.Type;
periodic.Classify = request.Classify ?? string.Empty;
periodic.Reason = request.Reason ?? string.Empty;
periodic.IsEnabled = request.IsEnabled;
periodic.UpdateTime = DateTime.Now;
// 重新计算下次执行时间
periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now);
var success = await periodicRepository.UpdateAsync(periodic);
if (!success)
{
throw new BusinessException("更新周期性账单失败");
}
}
public async Task DeleteByIdAsync(long id)
{
var success = await periodicRepository.DeleteAsync(id);
if (!success)
{
throw new BusinessException("删除周期性账单失败");
}
}
public async Task ToggleEnabledAsync(long id, bool enabled)
{
var periodic = await periodicRepository.GetByIdAsync(id);
if (periodic == null)
{
throw new NotFoundException("周期性账单不存在");
}
periodic.IsEnabled = enabled;
periodic.UpdateTime = DateTime.Now;
var success = await periodicRepository.UpdateAsync(periodic);
if (!success)
{
throw new BusinessException("操作失败");
}
}
private static PeriodicResponse MapToResponse(TransactionPeriodic periodic)
{
return new PeriodicResponse
{
Id = periodic.Id,
PeriodicType = periodic.PeriodicType,
PeriodicConfig = periodic.PeriodicConfig,
Amount = periodic.Amount,
Type = periodic.Type,
Classify = periodic.Classify,
Reason = periodic.Reason,
IsEnabled = periodic.IsEnabled,
NextExecuteTime = periodic.NextExecuteTime,
CreateTime = periodic.CreateTime,
UpdateTime = periodic.UpdateTime
};
}
}

View File

@@ -0,0 +1,203 @@
using Application.Dto.Statistics;
using Service.Transaction;
namespace Application;
/// <summary>
/// 交易统计应用服务接口
/// </summary>
public interface ITransactionStatisticsApplication
{
// === 新统一接口(推荐使用) ===
/// <summary>
/// 按日期范围获取每日统计(新统一接口)
/// </summary>
Task<List<DailyStatisticsDto>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
/// <summary>
/// 按日期范围获取汇总统计(新统一接口)
/// </summary>
Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate);
/// <summary>
/// 按日期范围获取分类统计(新统一接口)
/// </summary>
Task<List<CategoryStatistics>> GetCategoryStatisticsByRangeAsync(DateTime startDate, DateTime endDate, TransactionType type);
/// <summary>
/// 获取趋势统计数据
/// </summary>
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
// === 旧接口(保留用于向后兼容,建议迁移到新接口) ===
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month);
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month);
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<DailyStatisticsDto>> GetWeeklyStatisticsAsync(DateTime startDate, DateTime endDate);
[Obsolete("请使用 GetSummaryByRangeAsync")]
Task<MonthlyStatistics> GetRangeStatisticsAsync(DateTime startDate, DateTime endDate);
[Obsolete("请使用 GetSummaryByRangeAsync")]
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
[Obsolete("请使用 GetCategoryStatisticsByRangeAsync")]
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
[Obsolete("请使用 GetCategoryStatisticsByRangeAsync")]
Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(string startDate, string endDate, TransactionType type);
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex, int pageSize);
}
/// <summary>
/// 交易统计应用服务实现
/// </summary>
public class TransactionStatisticsApplication(
ITransactionStatisticsService statisticsService,
IConfigService configService,
ILogger<TransactionStatisticsApplication> logger
) : ITransactionStatisticsApplication
{
// === 新统一接口实现 ===
/// <summary>
/// 按日期范围获取每日统计(新统一接口)
/// </summary>
public async Task<List<DailyStatisticsDto>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null)
{
// 如果未指定 savingClassify从配置读取
savingClassify ??= await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day,
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
/// <summary>
/// 按日期范围获取汇总统计(新统一接口)
/// </summary>
public async Task<MonthlyStatistics> GetSummaryByRangeAsync(DateTime startDate, DateTime endDate)
{
return await statisticsService.GetSummaryByRangeAsync(startDate, endDate);
}
/// <summary>
/// 按日期范围获取分类统计(新统一接口)
/// </summary>
public async Task<List<CategoryStatistics>> GetCategoryStatisticsByRangeAsync(DateTime startDate, DateTime endDate, TransactionType type)
{
return await statisticsService.GetCategoryStatisticsByDateRangeAsync(startDate, endDate, type);
}
// === 旧接口实现(保留用于向后兼容) ===
public async Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
var sortedStats = statistics.OrderBy(s => DateTime.Parse(s.Key)).ToList();
var result = new List<BalanceStatisticsDto>();
decimal cumulativeBalance = 0;
foreach (var item in sortedStats)
{
var dailyBalance = item.Value.income - item.Value.expense;
cumulativeBalance += dailyBalance;
result.Add(new BalanceStatisticsDto(DateTime.Parse(item.Key).Day, cumulativeBalance));
}
return result;
}
public async Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day, // 从完整日期字符串 "yyyy-MM-dd" 中提取 day
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
public async Task<List<DailyStatisticsDto>> GetWeeklyStatisticsAsync(DateTime startDate, DateTime endDate)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
return statistics.Select(s => new DailyStatisticsDto(
DateTime.Parse(s.Key).Day, // 从完整日期字符串 "yyyy-MM-dd" 中提取 day
s.Value.count,
s.Value.expense,
s.Value.income,
s.Value.saving
)).ToList();
}
public async Task<MonthlyStatistics> GetRangeStatisticsAsync(DateTime startDate, DateTime endDate)
{
var records = await statisticsService.GetDailyStatisticsByRangeAsync(startDate, endDate, null);
var totalExpense = records.Sum(r => r.Value.expense);
var totalIncome = records.Sum(r => r.Value.income);
var totalCount = records.Sum(r => r.Value.count);
var expenseCount = records.Count(r => r.Value.expense > 0);
var incomeCount = records.Count(r => r.Value.income > 0);
return new MonthlyStatistics
{
Year = startDate.Year,
Month = startDate.Month,
TotalExpense = totalExpense,
TotalIncome = totalIncome,
Balance = totalIncome - totalExpense,
ExpenseCount = expenseCount,
IncomeCount = incomeCount,
TotalCount = totalCount
};
}
public async Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month)
{
return await statisticsService.GetMonthlyStatisticsAsync(year, month);
}
public async Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type)
{
return await statisticsService.GetCategoryStatisticsAsync(year, month, type);
}
public async Task<List<CategoryStatistics>> GetCategoryStatisticsByDateRangeAsync(string startDate, string endDate, TransactionType type)
{
var start = DateTime.Parse(startDate);
var end = DateTime.Parse(endDate);
return await statisticsService.GetCategoryStatisticsByDateRangeAsync(start, end, type);
}
public async Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount)
{
return await statisticsService.GetTrendStatisticsAsync(startYear, startMonth, monthCount);
}
public async Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex, int pageSize)
{
return await statisticsService.GetReasonGroupsAsync(pageIndex, pageSize);
}
}