Initial project import
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user