286 lines
11 KiB
C#
286 lines
11 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.IsIncome(transaction.TransferDirection))
|
|
.Sum(transaction => transaction.Amount);
|
|
var expenses = list
|
|
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
|
|
.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" or "transfer"))
|
|
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 transfer"));
|
|
|
|
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.Counterparty != null && t.Counterparty.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 (type == "transfer") query = query.Where(t => t.Type == TransactionType.Transfer);
|
|
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);
|
|
}
|