Files
jizhi/backend/MiaoJiZhang.Api/Controllers/ReportsController.cs
T
2026-07-24 23:11:20 +08:00

285 lines
10 KiB
C#

using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/reports")]
public class ReportsController(
AppDbContext db,
ReplyService reply,
LedgerResolver ledgers,
AiPermissionService aiPermissions) : ControllerBase
{
private long Uid => long.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ??
User.FindFirstValue("sub")!);
[HttpGet("weekly")]
public async Task<ActionResult<PeriodReportDto>> Weekly(
[FromQuery] DateTime? date = null,
[FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var selected = DateTime.SpecifyKind(
(date ?? ChinaClock.Now).Date,
DateTimeKind.Unspecified);
var (start, end) = ChinaClock.WeekRangeUtc(selected);
var localStart = ChinaClock.ToLocal(start).Date;
var localEnd = ChinaClock.ToLocal(end).Date.AddDays(-1);
return Ok(await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"weekly",
$"{localStart:MM月dd日} - {localEnd:MM月dd日}",
groupByMonth: false));
}
[HttpGet("monthly")]
public async Task<ActionResult<MonthlyReportDto>> Monthly(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
if (month is < 1 or > 12)
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var report = await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"monthly",
$"{year}年{month}月",
groupByMonth: false);
return Ok(new MonthlyReportDto(
year,
month,
report.Income,
report.Expense,
report.Balance,
report.Count,
report.AiRatio,
report.PeakLabel,
report.PeakAmount,
report.PeakNote,
report.TopCategory,
report.TopCategoryAmount,
report.TopCategoryPercent,
report.CategoryRanking,
report.Commentary));
}
[HttpGet("yearly")]
public async Task<ActionResult<PeriodReportDto>> Yearly(
[FromQuery] int year,
[FromQuery] long? ledgerId = null)
{
if (year is < 2000 or > 2200)
return BadRequest(new ApiError("YEAR_INVALID", "年份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var (start, end) = ChinaClock.YearRangeUtc(year);
return Ok(await BuildAsync(
resolvedLedgerId.Value,
start,
end,
"yearly",
$"{year}年",
groupByMonth: true));
}
private async Task<PeriodReportDto> BuildAsync(
long ledgerId,
DateTime start,
DateTime end,
string periodType,
string periodLabel,
bool groupByMonth)
{
var list = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == ledgerId &&
transaction.OccurredAt >= start &&
transaction.OccurredAt < end)
.ToListAsync();
var income = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Sum(transaction => transaction.Amount);
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.ToList();
var expense = expenses.Sum(transaction => transaction.Amount);
var aiCount = list.Count(
transaction => transaction.Source.IsAiAssisted());
var aiRatio = list.Count == 0
? 0
: (double)aiCount / list.Count * 100;
var peak = expenses
.GroupBy(transaction =>
{
var local = ChinaClock.ToLocal(transaction.OccurredAt);
return groupByMonth
? new DateTime(local.Year, local.Month, 1)
: local.Date;
})
.OrderByDescending(group => group.Sum(item => item.Amount))
.FirstOrDefault();
var categoryRanking = expenses
.GroupBy(transaction => new
{
transaction.Category.Name,
transaction.Category.IconKey,
transaction.Category.ColorKey,
})
.Select(group => new CategoryRankDto(
group.Key.Name,
group.Key.IconKey,
group.Sum(item => item.Amount),
expense == 0
? 0
: (double)(group.Sum(item => item.Amount) / expense * 100),
group.Key.ColorKey))
.OrderByDescending(item => item.Amount)
.ThenBy(item => item.Name)
.Take(5)
.ToList();
var topCategory = categoryRanking.FirstOrDefault();
var commentary = await aiPermissions.IsEnabledAsync(Uid, HttpContext.RequestAborted)
? await reply.PeriodRoastAsync(
Uid,
periodType switch
{
"weekly" => "这周",
"yearly" => "这一年",
_ => "这个月",
},
income,
expense,
topCategory?.Name ?? "无",
topCategory?.Amount ?? 0)
: string.Empty;
var localStart = ChinaClock.ToLocal(start).Date;
var localEnd = ChinaClock.ToLocal(end).Date;
return new PeriodReportDto(
periodType,
periodLabel,
localStart,
localEnd,
income,
expense,
income - expense,
list.Count,
aiRatio,
peak is null
? null
: groupByMonth
? $"{peak.Key.Month}月"
: $"{peak.Key.Month}月{peak.Key.Day}日",
peak?.Sum(item => item.Amount) ?? 0,
peak?.OrderByDescending(item => item.Amount).First().Note,
topCategory?.Name,
topCategory?.Amount ?? 0,
expense == 0 || topCategory is null
? 0
: (double)(topCategory.Amount / expense * 100),
categoryRanking,
commentary);
}
}
[ApiController]
[Authorize]
[Route("api/search")]
public class SearchController(AppDbContext db, LedgerResolver ledgers) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
/// <summary>账单搜索:过滤全部在数据库执行,使用时间和 ID 作为稳定游标。</summary>
[HttpGet]
public async Task<ActionResult<List<TransactionDto>>> Search(
[FromQuery] string? q,
[FromQuery] long? ledgerId,
[FromQuery] long? categoryId,
[FromQuery] string? type,
[FromQuery] decimal? minAmount,
[FromQuery] decimal? maxAmount,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] bool aiOnly = false,
[FromQuery] DateTime? beforeOccurredAt = null,
[FromQuery] long? beforeId = null,
[FromQuery] int limit = 50)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
if (type is not null && type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income"));
var query = db.Transactions.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
if (!string.IsNullOrWhiteSpace(q))
{
var keyword = q.Trim();
query = query.Where(t =>
(t.Note != null && t.Note.Contains(keyword)) ||
t.Category.Name.Contains(keyword) ||
(t.SourceText != null && t.SourceText.Contains(keyword)));
}
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
if (to.HasValue) query = query.Where(t => t.OccurredAt < NormalizeTime(to.Value));
if (aiOnly) query = query.Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source));
if (beforeOccurredAt.HasValue)
{
var cursorTime = NormalizeTime(beforeOccurredAt.Value);
query = query.Where(t =>
t.OccurredAt < cursorTime ||
(t.OccurredAt == cursorTime && (!beforeId.HasValue || t.Id < beforeId.Value)));
}
var list = await query
.OrderByDescending(t => t.OccurredAt)
.ThenByDescending(t => t.Id)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync();
return Ok(list.Select(t => TransactionsController.ToDto(t, t.Category)).ToList());
}
private static DateTime NormalizeTime(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
}