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> 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> 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> 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 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")!); /// 账单搜索:过滤全部在数据库执行,使用时间和 ID 作为稳定游标。 [HttpGet] public async Task>> 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); }