using System.Security.Claims; using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Domain.Entities; 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/ledgers")] public class LedgersController(AppDbContext db) : ControllerBase { private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); [HttpGet] public async Task List() => Ok(await db.Ledgers.Where(l => l.OwnerId == Uid) .OrderByDescending(l => l.IsDefault).ThenBy(l => l.CreatedAt) .Select(l => new { l.Id, l.Name, l.IconKey, l.IsDefault, TxCount = db.Transactions.IgnoreQueryFilters().Count(t => t.LedgerId == l.Id && t.UserId == Uid) }) .ToListAsync()); [HttpPost] public async Task Create([FromBody] CreateLedgerReq req) { if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 12) return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字")); var maxCount = int.TryParse((await db.AppConfigs.FirstOrDefaultAsync(c => c.Key == "system.max_ledgers_per_user"))?.Value, out var v) ? v : 10; if (await db.Ledgers.CountAsync(l => l.OwnerId == Uid) >= maxCount) return BadRequest(new ApiError("LIMIT_REACHED", $"每人最多 {maxCount} 个账本")); var ledger = new Ledger { OwnerId = Uid, Name = req.Name.Trim(), IconKey = req.IconKey ?? "wallet" }; db.Ledgers.Add(ledger); await db.SaveChangesAsync(); return Ok(new { ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault, TxCount = 0 }); } [HttpPut("{id:long}/default")] public async Task SetDefault(long id) { var target = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid); if (target is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在")); await db.Ledgers.Where(l => l.OwnerId == Uid && l.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(l => l.IsDefault, false)); target.IsDefault = true; await db.SaveChangesAsync(); return NoContent(); } [HttpPut("{id:long}")] public async Task Rename(long id, UpdateLedgerReq req) { if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 12) return BadRequest(new ApiError("NAME_INVALID", "账本名 1-12 个字")); var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid); if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在")); ledger.Name = req.Name.Trim(); if (!string.IsNullOrWhiteSpace(req.IconKey)) ledger.IconKey = req.IconKey.Trim(); await db.SaveChangesAsync(); return Ok(new LedgerDto(ledger.Id, ledger.Name, ledger.IconKey, ledger.IsDefault)); } [HttpPut("reorder")] public async Task Reorder(ReorderCategoriesRequest req) { if (req.Type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count()) return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项")); var type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense; var categories = await db.Categories .Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted) .ToListAsync(); if (categories.Count != req.CategoryIds.Count || categories.Select(c => c.Id).ToHashSet() .SetEquals(req.CategoryIds) is false) return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序")); var byId = categories.ToDictionary(c => c.Id); for (var index = 0; index < req.CategoryIds.Count; index++) byId[req.CategoryIds[index]].SortOrder = 1000 + index; await db.SaveChangesAsync(); return NoContent(); } [HttpDelete("{id:long}")] public async Task Delete(long id) { var ledger = await db.Ledgers.FirstOrDefaultAsync(l => l.Id == id && l.OwnerId == Uid); if (ledger is null) return NotFound(new ApiError("LEDGER_NOT_FOUND", "账本不存在")); if (ledger.IsDefault) return Conflict(new ApiError("DEFAULT_LEDGER", "默认账本不能删除,请先切换到其他账本")); var hasTransactions = await db.Transactions.IgnoreQueryFilters() .AnyAsync(t => t.UserId == Uid && t.LedgerId == id); var hasBudgets = await db.Budgets.AnyAsync(b => b.UserId == Uid && b.LedgerId == id); if (hasTransactions || hasBudgets) return Conflict(new ApiError("LEDGER_NOT_EMPTY", "仅可删除没有账单和预算的账本")); db.Ledgers.Remove(ledger); await db.SaveChangesAsync(); return NoContent(); } } public record CreateLedgerReq(string Name, string? IconKey); public record UpdateLedgerReq(string Name, string? IconKey); [ApiController] [Authorize] [Route("api/categories")] public class CategoriesController(AppDbContext db) : ControllerBase { internal static readonly HashSet AllowedIcons = [ "food", "cup", "cart", "house", "rent", "metro", "car", "plane", "phone", "wifi", "shirt", "beauty", "pill", "sport", "book", "baby", "pet", "game", "gift", "wallet", "money", "briefcase", "chart", "interest", "refund", "card", "insurance", "tax", "receipt", "camera", "target", "sparkle", "tag", ]; internal static readonly HashSet AllowedColors = [ "mint", "teal", "aqua", "cyan", "sky", "blue", "navy", "indigo", "violet", "plum", "orchid", "rose", "coral", "red", "orange", "amber", "peach", "sand", "lime", "olive", "forest", "slate", "cocoa", "graphite", ]; private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!); internal static string NormalizeIcon(string? iconKey) => iconKey?.Trim() switch { "shopping" => "cart", "transport" => "metro", "home" => "house", "salary" => "money", { Length: > 0 } value => value, _ => "tag", }; [HttpGet] public async Task>> List([FromQuery] string type = "expense") { if (type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var t = type == "income" ? TransactionType.Income : TransactionType.Expense; var list = await db.Categories .Where(c => !c.IsDeleted && c.Type == t && (c.UserId == null || c.UserId == Uid)) .OrderBy(c => c.SortOrder) .Select(c => new CategoryDto( c.Id, c.Name, c.IconKey, type, c.SortOrder, c.UserId != null, c.ColorKey)) .ToListAsync(); return Ok(list); } [HttpPost] public async Task> Create(CreateCategoryRequest req) { if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 8) return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字")); if (req.Type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var t = req.Type == "income" ? TransactionType.Income : TransactionType.Expense; var iconKey = NormalizeIcon(req.IconKey); if (!AllowedIcons.Contains(iconKey)) return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在")); var colorKey = string.IsNullOrWhiteSpace(req.ColorKey) ? "mint" : req.ColorKey.Trim(); if (!AllowedColors.Contains(colorKey)) return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在")); if (await db.Categories.AnyAsync(c => !c.IsDeleted && c.Type == t && c.Name == req.Name && (c.UserId == null || c.UserId == Uid))) return Conflict(new ApiError("NAME_TAKEN", "分类已存在")); var maxSort = await db.Categories .Where(c => c.Type == t && (c.UserId == null || c.UserId == Uid)) .MaxAsync(c => (int?)c.SortOrder) ?? 0; var cat = new Category { UserId = Uid, Type = t, Name = req.Name.Trim(), IconKey = iconKey, ColorKey = colorKey, SortOrder = Math.Max(999, maxSort) + 1, }; db.Categories.Add(cat); await db.SaveChangesAsync(); return Ok(new CategoryDto( cat.Id, cat.Name, cat.IconKey, req.Type, cat.SortOrder, true, cat.ColorKey)); } [HttpPut("{id:long}")] public async Task> Update(long id, UpdateCategoryRequest req) { if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Trim().Length > 8) return BadRequest(new ApiError("NAME_INVALID", "分类名 1-8 个字")); var category = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == Uid && !c.IsDeleted); if (category is null) return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类")); var name = req.Name.Trim(); if (await db.Categories.AnyAsync(c => c.Id != id && !c.IsDeleted && c.Type == category.Type && c.Name == name && (c.UserId == null || c.UserId == Uid))) return Conflict(new ApiError("NAME_TAKEN", "分类已存在")); var iconKey = NormalizeIcon(req.IconKey); if (!AllowedIcons.Contains(iconKey)) return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在")); var colorKey = req.ColorKey is null ? category.ColorKey : req.ColorKey.Trim(); if (!AllowedColors.Contains(colorKey)) return BadRequest(new ApiError("COLOR_INVALID", "分类颜色不存在")); category.Name = name; category.IconKey = iconKey; category.ColorKey = colorKey; category.SortOrder = Math.Max(1000, req.SortOrder); await db.SaveChangesAsync(); return Ok(new CategoryDto( category.Id, category.Name, category.IconKey, category.Type == TransactionType.Income ? "income" : "expense", category.SortOrder, true, category.ColorKey)); } [HttpPut("reorder")] public async Task Reorder(ReorderCategoriesRequest req) { if (req.Type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); if (req.CategoryIds.Count != req.CategoryIds.Distinct().Count()) return BadRequest(new ApiError("ORDER_INVALID", "分类排序中存在重复项")); var type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense; var categories = await db.Categories .Where(c => c.UserId == Uid && c.Type == type && !c.IsDeleted) .ToListAsync(); if (categories.Count != req.CategoryIds.Count || categories.Select(c => c.Id).ToHashSet() .SetEquals(req.CategoryIds) is false) return BadRequest(new ApiError("ORDER_INVALID", "请提交完整的自定义分类顺序")); var byId = categories.ToDictionary(c => c.Id); for (var index = 0; index < req.CategoryIds.Count; index++) byId[req.CategoryIds[index]].SortOrder = 1000 + index; await db.SaveChangesAsync(); return NoContent(); } [HttpDelete("{id:long}")] public async Task Delete(long id) { var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == Uid); if (cat is null) return NotFound(new ApiError("CATEGORY_NOT_FOUND", "分类不存在或非自定义分类")); cat.IsDeleted = true; await db.SaveChangesAsync(); return NoContent(); } } public record ReorderCategoriesRequest(string Type, List CategoryIds);