Files
jizhi/backend/MiaoJiZhang.Api/Controllers/LedgersController.cs
T
2026-07-24 23:11:20 +08:00

270 lines
12 KiB
C#

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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> 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<string> 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<ActionResult<List<CategoryDto>>> 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<ActionResult<CategoryDto>> 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<ActionResult<CategoryDto>> 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<IActionResult> 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<IActionResult> 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<long> CategoryIds);