353 lines
14 KiB
C#
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());
|
|
}
|
|
}
|