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> 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.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(); 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 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 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> 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.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(); 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> 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 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 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>> 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()); return Ok(await db.Stickers.Where(s => s.IsEnabled) .Select(s => new StickerDto(s.Key, s.Label, s.GroupKey)).ToListAsync()); } }