270 lines
12 KiB
C#
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);
|
|
|