Files
jizhi/backend/MiaoJiZhang.Api/Controllers/BudgetsController.cs
T

353 lines
14 KiB
C#

using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/budgets")]
public class BudgetsController(
AppDbContext db,
LedgerResolver ledgers,
BudgetRecommendationService recommendations) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
[HttpGet]
public async Task<ActionResult<BudgetsResponse>> Get(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var period = year * 100 + month;
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var targetLedgerId = resolvedLedgerId.Value;
var rows = await db.Budgets
.Where(b => b.UserId == Uid && b.LedgerId == targetLedgerId && (b.Period == period || b.Period == 0))
.ToListAsync();
var budgets = rows
.GroupBy(b => b.CategoryId)
.Select(g => g.FirstOrDefault(b => b.Period == period) ?? g.First(b => b.Period == 0))
.ToList();
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var spentByCat = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == targetLedgerId &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= start && t.OccurredAt < end)
.GroupBy(t => t.CategoryId)
.Select(g => new { g.Key, Amount = g.Sum(t => t.Amount) })
.ToDictionaryAsync(x => x.Key, x => x.Amount);
var totalSpent = spentByCat.Values.Sum();
var cats = await db.Categories
.Where(c => !c.IsDeleted && (c.UserId == null || c.UserId == Uid))
.ToDictionaryAsync(c => c.Id);
BudgetItemDto? total = null;
var items = new List<BudgetItemDto>();
foreach (var budget in budgets)
{
if (budget.CategoryId is null)
{
total = new BudgetItemDto(null, null, null, budget.Amount, totalSpent, budget.Period == 0);
}
else if (cats.TryGetValue(budget.CategoryId.Value, out var category))
{
items.Add(new BudgetItemDto(
category.Id,
category.Name,
category.IconKey,
budget.Amount,
spentByCat.GetValueOrDefault(category.Id, 0),
budget.Period == 0,
category.ColorKey));
}
}
return Ok(new BudgetsResponse(
year,
month,
total,
items.OrderByDescending(i => i.Amount <= 0 ? 0 : i.Spent / i.Amount).ToList()));
}
[HttpPut]
public async Task<IActionResult> Upsert(
UpsertBudgetRequest req,
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
if (req.Recurring)
{
var overrideRow = await db.Budgets.FirstOrDefaultAsync(b =>
b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == year * 100 + month && b.CategoryId == req.CategoryId);
if (overrideRow is not null) db.Budgets.Remove(overrideRow);
}
await UpsertCore(ledgerId, req.Recurring ? 0 : year * 100 + month, req.CategoryId, req.Amount);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPut("batch")]
public async Task<IActionResult> Batch(
ApplyBudgetBatchRequest req,
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
if (req.Items.Count == 0) return BadRequest(new ApiError("BUDGET_EMPTY", "没有可应用的预算"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
var targetPeriod = req.Recurring ? 0 : year * 100 + month;
if (req.Recurring)
{
var affectedCategories = req.Items.Select(i => i.CategoryId).ToHashSet();
var overrides = await db.Budgets
.Where(b => b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == year * 100 + month)
.ToListAsync();
db.Budgets.RemoveRange(overrides.Where(b => affectedCategories.Contains(b.CategoryId)));
}
foreach (var item in req.Items)
await UpsertCore(ledgerId, targetPeriod, item.CategoryId, item.Amount);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("recommendations")]
[RequireAiPermission]
public async Task<ActionResult<BudgetRecommendationResponse>> Recommendations(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery(Name = "ledgerId")] long? requestedLedgerId = null)
{
if (month is < 1 or > 12) return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, requestedLedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var ledgerId = resolvedLedgerId.Value;
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
var historyStart = currentStart.AddMonths(-3);
var transactions = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
.ToListAsync();
var categories = await db.Categories
.Where(c => !c.IsDeleted && c.Type == TransactionType.Expense &&
(c.UserId == null || c.UserId == Uid))
.ToDictionaryAsync(c => c.Id);
var currentTransactions = transactions
.Where(t => t.OccurredAt >= currentStart && t.OccurredAt < currentEnd)
.ToList();
var currentByCategory = currentTransactions
.GroupBy(t => t.CategoryId)
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
var localNow = ChinaClock.Now;
var projectionAllowed = localNow.Year == year && localNow.Month == month &&
localNow.Day >= 14 && currentTransactions.Count >= 10;
var categoryIds = transactions.Select(t => t.CategoryId).Distinct().ToList();
var suggestions = new List<BudgetRecommendationItemDto>();
foreach (var categoryId in categoryIds)
{
if (!categories.TryGetValue(categoryId, out var category)) continue;
var history = Enumerable.Range(1, 3)
.Select(offset =>
{
var start = currentStart.AddMonths(-offset);
var end = start.AddMonths(1);
return transactions
.Where(t => t.CategoryId == categoryId && t.OccurredAt >= start && t.OccurredAt < end)
.Sum(t => t.Amount);
})
.Where(value => value > 0)
.OrderBy(value => value)
.ToList();
var currentSpent = currentByCategory.GetValueOrDefault(categoryId, 0);
decimal baseline;
string confidence;
if (history.Count > 0)
{
baseline = Median(history);
confidence = history.Count >= 3 ? "high" : "medium";
if (projectionAllowed && currentSpent > 0)
{
var projection = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
baseline = baseline * 0.8m + projection * 0.2m;
}
}
else if (projectionAllowed && currentSpent > 0)
{
baseline = currentSpent / localNow.Day * DateTime.DaysInMonth(year, month);
confidence = "low";
}
else
{
continue;
}
var p75 = history.Count == 0
? baseline
: history[(int)Math.Ceiling(history.Count * 0.75) - 1];
var target = baseline * 1.05m;
var cap = p75 * 1.2m;
target = Math.Min(target, cap);
if (currentSpent >= target) target = currentSpent * 1.05m;
target = RoundFriendly(target);
suggestions.Add(new BudgetRecommendationItemDto(
categoryId,
category.Name,
category.IconKey,
target,
currentSpent,
history.Count == 0 ? baseline : history.Average(),
confidence,
category.ColorKey));
}
var message = suggestions.Count == 0
? "历史数据不足。至少记录 1 个完整月,或本月满 14 天且达到 10 笔支出后再试。"
: "建议基于最近 3 个完整月,并确保不低于本月已发生支出。";
var currentSpentTotal = currentTransactions.Sum(t => t.Amount);
return Ok(new BudgetRecommendationResponse(
year,
month,
Math.Max(suggestions.Sum(s => s.SuggestedAmount), currentSpentTotal),
message,
suggestions.OrderByDescending(s => s.SuggestedAmount).ToList()));
}
[HttpPost("recommendations/refine")]
[RequireAiPermission]
[EnableRateLimiting("ai")]
public async Task<ActionResult<BudgetRecommendationResponse>> RefineRecommendation(
RefineBudgetRecommendationRequest request,
CancellationToken ct)
{
if (request.Year is < 2000 or > 2200 || request.Month is < 1 or > 12)
return BadRequest(new ApiError("MONTH_INVALID", "月份无效"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, request.LedgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
try
{
return Ok(await recommendations.RefineAsync(
Uid,
resolvedLedgerId.Value,
request,
ct));
}
catch (ArgumentException exception)
{
return BadRequest(new ApiError("BUDGET_DRAFT_INVALID", exception.Message));
}
catch (InvalidOperationException exception)
{
return StatusCode(503, new ApiError("BUDGET_AI_UNAVAILABLE", exception.Message));
}
catch (HttpRequestException exception)
{
return StatusCode(502, new ApiError("BUDGET_AI_FAILED", exception.Message));
}
}
private async Task UpsertCore(long ledgerId, int period, long? categoryId, decimal amount)
{
if (categoryId.HasValue && !await db.Categories.AnyAsync(c =>
c.Id == categoryId && !c.IsDeleted && c.Type == TransactionType.Expense &&
(c.UserId == null || c.UserId == Uid)))
throw new ArgumentException("预算分类无效");
var existing = await db.Budgets.FirstOrDefaultAsync(b =>
b.UserId == Uid && b.LedgerId == ledgerId &&
b.Period == period && b.CategoryId == categoryId);
if (amount <= 0)
{
if (existing != null) db.Budgets.Remove(existing);
}
else if (existing != null)
{
existing.Amount = amount;
}
else
{
db.Budgets.Add(new Budget
{
UserId = Uid,
LedgerId = ledgerId,
Period = period,
CategoryId = categoryId,
Amount = amount,
});
}
}
private static decimal Median(IReadOnlyList<decimal> values)
{
if (values.Count == 0) return 0;
var middle = values.Count / 2;
return values.Count % 2 == 0
? (values[middle - 1] + values[middle]) / 2
: values[middle];
}
private static decimal RoundFriendly(decimal value)
{
var unit = value < 100 ? 10m : value < 1000 ? 50m : 100m;
return Math.Ceiling(value / unit) * unit;
}
private async Task<long> DefaultLedgerId() =>
await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).Select(l => l.Id).FirstAsync();
}
[ApiController]
[Authorize]
[Route("api/stickers")]
public class StickersController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<StickerDto>>> List()
{
var enabled = await db.AppConfigs
.Where(config => config.Key == "feature.sticker_enabled")
.Select(config => config.Value)
.FirstOrDefaultAsync();
if (enabled?.Equals("false", StringComparison.OrdinalIgnoreCase) == true)
return Ok(new List<StickerDto>());
return Ok(await db.Stickers.Where(s => s.IsEnabled)
.Select(s => new StickerDto(s.Key, s.Label, s.GroupKey)).ToListAsync());
}
}