Initial project import
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user