Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
@@ -0,0 +1,145 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[AdminAuth(AdminRoles.SuperAdmin)]
[Route("api/admin/security")]
public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
{
[HttpGet("accounts")]
public async Task<IActionResult> Accounts(CancellationToken ct)
{
var users = await db.AdminUsers.AsNoTracking()
.OrderBy(item => item.Username)
.Select(item => new
{
item.Id,
item.Username,
item.Role,
item.IsActive,
item.MustChangePassword,
item.LastLoginAt,
item.CreatedAt,
activeSessions = item.Sessions.Count(session =>
!session.RevokedAt.HasValue && session.ExpiresAt > DateTime.UtcNow),
})
.ToListAsync(ct);
return Ok(users);
}
[HttpPost("accounts")]
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
{
var username = request.Username.Trim();
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
!AdminRoles.All.Contains(request.Role))
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
return Conflict(new ApiError("ADMIN_ACCOUNT_EXISTS", "管理员用户名已存在"));
var now = DateTime.UtcNow;
var user = new AdminUser
{
Username = username,
PasswordHash = AdminSessionService.HashPassword(request.Password),
Role = request.Role,
IsActive = true,
MustChangePassword = true,
CreatedAt = now,
UpdatedAt = now,
};
db.AdminUsers.Add(user);
await db.SaveChangesAsync(ct);
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
}
[HttpPut("accounts/{id:long}")]
public async Task<IActionResult> Update(
long id,
UpdateAdminUserRequest request,
CancellationToken ct)
{
if (!AdminRoles.All.Contains(request.Role))
return BadRequest(new ApiError("ADMIN_ROLE_INVALID", "管理员角色无效"));
var user = await db.AdminUsers.FindAsync([id], ct);
if (user is null) return NotFound();
if (user.Role == AdminRoles.SuperAdmin &&
(!request.IsActive || request.Role != AdminRoles.SuperAdmin) &&
await ActiveSuperAdminCount(ct) <= 1)
{
return Conflict(new ApiError("LAST_SUPER_ADMIN", "不能停用或降级最后一个超级管理员"));
}
user.Role = request.Role;
user.IsActive = request.IsActive;
user.MustChangePassword = request.MustChangePassword;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await RevokeSessions(id, ct);
await db.SaveChangesAsync(ct);
return Ok(new { user.Id, user.Username, user.Role, user.IsActive, user.MustChangePassword });
}
[HttpPut("accounts/{id:long}/password")]
public async Task<IActionResult> ResetPassword(
long id,
ResetAdminPasswordRequest request,
CancellationToken ct)
{
if (request.Password.Length is < 12 or > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
var user = await db.AdminUsers.FindAsync([id], ct);
if (user is null) return NotFound();
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
user.MustChangePassword = true;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await RevokeSessions(id, ct);
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("accounts/{id:long}/revoke-sessions")]
public async Task<IActionResult> Revoke(long id, CancellationToken ct)
{
if (!await db.AdminUsers.AnyAsync(item => item.Id == id, ct)) return NotFound();
await RevokeSessions(id, ct);
return NoContent();
}
[HttpGet("audit")]
public async Task<IActionResult> Audit(
[FromQuery] string? username,
[FromQuery] int page = 1,
[FromQuery] int limit = 50,
CancellationToken ct = default)
{
page = Math.Max(1, page);
limit = Math.Clamp(limit, 1, 100);
var query = db.AdminAuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(username))
{
var term = username.Trim();
query = query.Where(item => item.Username != null && item.Username.Contains(term));
}
var total = await query.CountAsync(ct);
var list = await query.OrderByDescending(item => item.CreatedAt)
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
return Ok(new { total, page, list });
}
private Task<int> ActiveSuperAdminCount(CancellationToken ct) => db.AdminUsers.CountAsync(
item => item.IsActive && item.Role == AdminRoles.SuperAdmin,
ct);
private Task<int> RevokeSessions(long userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
return db.AdminSessions.Where(item => item.AdminUserId == userId && !item.RevokedAt.HasValue)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
}
}
@@ -0,0 +1,85 @@
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Controllers;
[ApiController]
[Route("api/admin/auth")]
public sealed class AdminAuthController(
AppDbContext db,
AdminSessionService sessions) : ControllerBase
{
[HttpPost("login")]
[EnableRateLimiting("admin-auth")]
public async Task<IActionResult> Login(AdminLoginRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password))
return BadRequest(new ApiError("ADMIN_LOGIN_INVALID", "请输入用户名和密码"));
var result = await sessions.LoginAsync(
HttpContext,
request.Username,
request.Password,
ct);
return result is null
? Unauthorized(new ApiError("ADMIN_LOGIN_FAILED", "用户名或密码错误,账号也可能已锁定"))
: Ok(ToResponse(result.Principal, result.CsrfToken));
}
[HttpGet("me")]
[AdminAuth]
public async Task<IActionResult> Me(CancellationToken ct)
{
var principal = AdminRequestContext.Principal(HttpContext)!;
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
return Ok(ToResponse(principal, csrfToken));
}
[HttpPost("logout")]
[AdminAuth]
public async Task<IActionResult> Logout(CancellationToken ct)
{
var principal = AdminRequestContext.Principal(HttpContext)!;
await sessions.LogoutAsync(HttpContext, principal.SessionId, ct);
return NoContent();
}
[HttpPut("password")]
[AdminAuth]
public async Task<IActionResult> ChangePassword(
AdminChangePasswordRequest request,
CancellationToken ct)
{
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
var principal = AdminRequestContext.Principal(HttpContext)!;
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
return BadRequest(new ApiError("ADMIN_PASSWORD_INCORRECT", "当前密码不正确"));
if (BCrypt.Net.BCrypt.Verify(request.NewPassword, user.PasswordHash))
return BadRequest(new ApiError("ADMIN_PASSWORD_UNCHANGED", "新密码不能与当前密码相同"));
user.PasswordHash = AdminSessionService.HashPassword(request.NewPassword);
user.MustChangePassword = false;
user.AuthVersion++;
user.UpdatedAt = DateTime.UtcNow;
await sessions.RevokeOtherSessionsAsync(user.Id, principal.SessionId, user.AuthVersion, ct);
await db.SaveChangesAsync(ct);
var csrfToken = await sessions.RotateCsrfAsync(principal.SessionId, ct);
var updated = principal with { MustChangePassword = false };
HttpContext.Items[AdminRequestContext.PrincipalKey] = updated;
return Ok(ToResponse(updated, csrfToken));
}
private static object ToResponse(AdminPrincipal principal, string csrfToken) => new
{
id = principal.UserId,
principal.Username,
principal.Role,
principal.MustChangePassword,
csrfToken,
};
}
@@ -45,7 +45,9 @@ public class BudgetsController(
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)
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
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);
@@ -152,7 +154,8 @@ public class BudgetsController(
var transactions = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= historyStart && t.OccurredAt < currentEnd)
.Select(t => new { t.CategoryId, t.Amount, t.OccurredAt })
.ToListAsync();
@@ -358,17 +358,21 @@ public class ChatController(
if (transactions.Count == 1)
{
var transaction = transactions[0];
var type = transaction.Type == TransactionType.Income
? "收入"
: "支出";
var type = transaction.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when transaction.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
return $"已记录{type}{transaction.Category.Name} ¥{transaction.Amount:F2}{tic}";
}
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
return $"已记录 {transactions.Count} 笔,其中收入 ¥{income:F2}、支出 ¥{expense:F2}{tic}";
}
@@ -530,4 +534,4 @@ public class ChatController(
transaction,
message.CreatedAt);
}
}
}
@@ -83,19 +83,22 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
"expense"));
}
var bill = drafts[0];
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
ct);
var bill = drafts[0];
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId &&
c.Type == bill.Type.CategoryType(bill.TransferDirection),
ct);
return Ok(new OcrParseResponse(
true,
category.Id,
category.Name,
category.IconKey,
bill.Amount,
bill.PaymentMethod,
bill.Note,
bill.Type == TransactionType.Income ? "income" : "expense"));
bill.PaymentMethod,
bill.Note,
bill.Type.ToWire(),
bill.TransferDirection.ToWire(),
bill.Counterparty));
}
/// <summary>上传截屏或小票,提取其中全部独立交易。</summary>
@@ -171,18 +174,24 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
foreach (var result in results.Take(20))
{
var normalizedType = result.Type.Trim().ToLowerInvariant();
if (normalizedType is not ("income" or "expense"))
var transferDirection = result.TransferDirection is "in" or "out"
? result.TransferDirection
: null;
if (normalizedType is not ("income" or "expense" or "transfer") ||
normalizedType == "transfer" && transferDirection is null)
{
items.Add(new ImageParseItemResponse(
false, 0, "待确认", "tag", "unknown",
result.Amount, result.PaymentMethod, result.Note,
result.OccurredAt));
result.OccurredAt,
transferDirection,
result.Counterparty));
continue;
}
var type = normalizedType == "income"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, type, result.CategoryName);
var categoryType = normalizedType == "income" || transferDirection == "in"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, categoryType, result.CategoryName);
items.Add(new ImageParseItemResponse(
true,
category.Id,
@@ -192,7 +201,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
result.Amount,
result.PaymentMethod,
result.Note,
result.OccurredAt));
result.OccurredAt,
transferDirection,
result.Counterparty));
}
var first = items[0];
@@ -206,7 +217,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
first.Note,
first.Type,
items,
first.OccurredAt));
first.OccurredAt,
first.TransferDirection,
first.Counterparty));
}
[HttpPost("recognition-batch")]
@@ -307,7 +320,12 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
candidate.RecognitionKind,
candidate.CategoryHint,
candidate.Confidence,
candidate.EvidenceIds ?? [])).ToList();
candidate.EvidenceIds ?? [],
candidate.TransferDirection,
candidate.Counterparty,
candidate.ProviderTransactionId,
candidate.RecognitionOccurrenceId,
candidate.IdentityConfidence)).ToList();
IReadOnlyList<RecognitionBatchModelAction>? modelActions;
try
@@ -366,11 +384,29 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
action = "keep";
reason = "撤销证据不足,已保留本地结果";
}
var type = model?.Type is "income" or "expense" ? model.Type : candidate.Type;
var type = model?.Type is "income" or "expense" or "transfer"
? model.Type
: candidate.Type;
var transferDirection = type == "transfer"
? model?.TransferDirection is "in" or "out"
? model.TransferDirection
: candidate.TransferDirection is "in" or "out"
? candidate.TransferDirection
: null
: null;
if (type == "transfer" && transferDirection is null)
{
action = "keep";
type = candidate.Type;
transferDirection = candidate.TransferDirection;
reason = "转账方向不明确,已保留本地结果";
}
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
var category = FindCategory(
categories,
type == "income" ? TransactionType.Income : TransactionType.Expense,
type == "income" || transferDirection == "in"
? TransactionType.Income
: TransactionType.Expense,
model?.CategoryName ?? candidate.CategoryHint ?? "其他");
result.Add(new RecognitionBatchActionResponse(
action,
@@ -385,7 +421,11 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
string.IsNullOrWhiteSpace(model?.Note) ? candidate.Merchant : model.Note,
model?.OccurredAt ?? candidate.OccurredAt,
confidence,
reason));
reason,
transferDirection,
string.IsNullOrWhiteSpace(model?.Counterparty)
? candidate.Counterparty
: model.Counterparty));
}
var usedEvidence = new HashSet<string>();
@@ -395,13 +435,16 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
!evidenceById.TryGetValue(model.EvidenceId, out var evidence) ||
!string.IsNullOrWhiteSpace(evidence.CandidateId) ||
!usedEvidence.Add(model.EvidenceId) ||
model.Type is not ("income" or "expense") ||
model.Type is not ("income" or "expense" or "transfer") ||
model.Type == "transfer" && model.TransferDirection is not ("in" or "out") ||
model.Amount is not > 0)
{
continue;
}
var type = model.Type == "income" ? TransactionType.Income : TransactionType.Expense;
var category = FindCategory(categories, type, model.CategoryName ?? "其他");
var categoryType = model.Type == "income" || model.TransferDirection == "in"
? TransactionType.Income
: TransactionType.Expense;
var category = FindCategory(categories, categoryType, model.CategoryName ?? "其他");
result.Add(new RecognitionBatchActionResponse(
"create",
model.ActionId,
@@ -415,7 +458,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
model.Note,
model.OccurredAt ?? evidence.CapturedAt,
model.Confidence,
model.Reason));
model.Reason,
model.TransferDirection,
model.Counterparty));
}
return result.Take(20).ToList();
}
@@ -129,10 +129,10 @@ public class ReportsController(
.ToListAsync();
var income = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Where(transaction => transaction.Type.IsIncome(transaction.TransferDirection))
.Sum(transaction => transaction.Amount);
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.ToList();
var expense = expenses.Sum(transaction => transaction.Amount);
var aiCount = list.Count(
@@ -241,8 +241,8 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
if (type is not null && type is not ("income" or "expense"))
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
if (type is not null && type is not ("income" or "expense" or "transfer"))
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var query = db.Transactions.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value);
@@ -251,12 +251,14 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
var keyword = q.Trim();
query = query.Where(t =>
(t.Note != null && t.Note.Contains(keyword)) ||
(t.Counterparty != null && t.Counterparty.Contains(keyword)) ||
t.Category.Name.Contains(keyword) ||
(t.SourceText != null && t.SourceText.Contains(keyword)));
}
if (categoryId.HasValue) query = query.Where(t => t.CategoryId == categoryId.Value);
if (type == "income") query = query.Where(t => t.Type == TransactionType.Income);
if (type == "expense") query = query.Where(t => t.Type == TransactionType.Expense);
if (type == "transfer") query = query.Where(t => t.Type == TransactionType.Transfer);
if (minAmount.HasValue) query = query.Where(t => t.Amount >= minAmount.Value);
if (maxAmount.HasValue) query = query.Where(t => t.Amount <= maxAmount.Value);
if (from.HasValue) query = query.Where(t => t.OccurredAt >= NormalizeTime(from.Value));
@@ -281,4 +283,3 @@ public class SearchController(AppDbContext db, LedgerResolver ledgers) : Control
private static DateTime NormalizeTime(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
}
@@ -27,19 +27,31 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var transferDirection = ParseTransferDirection(req.TransferDirection);
if (type == TransactionType.Transfer && !transferDirection.HasValue)
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
var categoryType = CategoryTypeFor(type.Value, transferDirection);
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
? null
: req.ClientRequestId.Trim();
var provider = NormalizeOptional(req.Provider, 24);
var providerTransactionId = NormalizeOptional(req.ProviderTransactionId, 128);
var occurrenceId = NormalizeOptional(req.RecognitionOccurrenceId, 64);
if (clientRequestId?.Length > 64)
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
if (clientRequestId != null)
if (providerTransactionId is not null && provider is null)
return BadRequest(new ApiError("PROVIDER_REQUIRED", "服务商交易号必须同时提供服务商"));
if (clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
{
var existing = await db.Transactions
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
var existing = await FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
}
@@ -48,7 +60,7 @@ public class TransactionsController(
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var cat = await db.Categories.FirstOrDefaultAsync(c =>
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (cat is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
@@ -64,9 +76,16 @@ public class TransactionsController(
Amount = req.Amount,
Note = req.Note,
PaymentMethod = req.PaymentMethod,
TransferDirection = transferDirection,
Counterparty = NormalizeOptional(req.Counterparty, 100),
Source = SourceFromWire(req.Source),
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
Provider = provider,
ProviderTransactionId = providerTransactionId,
RecognitionOccurrenceId = occurrenceId,
EvidenceFingerprint = NormalizeOptional(req.EvidenceFingerprint, 64),
RecognitionConfidence = NormalizeOptional(req.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -75,7 +94,7 @@ public class TransactionsController(
try
{
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -84,17 +103,19 @@ public class TransactionsController(
await writeScope.CommitAsync();
return Ok(ToDto(tx, cat));
}
catch (DbUpdateException) when (clientRequestId is not null)
catch (DbUpdateException) when (
clientRequestId is not null || providerTransactionId is not null || occurrenceId is not null)
{
await writeScope.RollbackAsync();
// Another channel may have committed the same recognition candidate
// after the initial lookup. Resolve the unique-key race as idempotent success.
db.Entry(tx).State = EntityState.Detached;
var existing = await db.Transactions
.AsNoTracking()
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
var existing = await FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId,
asNoTracking: true);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
@@ -108,13 +129,26 @@ public class TransactionsController(
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count ||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId))
.Select(item => item.RecognitionOccurrenceId!.Trim()).Distinct().Count() !=
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.RecognitionOccurrenceId)) ||
req.Items.Where(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId))
.Select(item => $"{item.Provider?.Trim()}\n{item.ProviderTransactionId!.Trim()}")
.Distinct().Count() !=
req.Items.Count(item => !string.IsNullOrWhiteSpace(item.ProviderTransactionId)))
{
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
}
if (req.Items.Any(item => item.Amount <= 0 ||
item.ClientRequestId.Length is < 1 or > 64 ||
ParseType(item.Type) is null))
ParseType(item.Type) is null ||
(ParseType(item.Type) == TransactionType.Transfer &&
ParseTransferDirection(item.TransferDirection) is null) ||
(ParseType(item.Type) != TransactionType.Transfer &&
!string.IsNullOrWhiteSpace(item.TransferDirection)) ||
(!string.IsNullOrWhiteSpace(item.ProviderTransactionId) &&
string.IsNullOrWhiteSpace(item.Provider))))
{
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
}
@@ -130,22 +164,27 @@ public class TransactionsController(
foreach (var item in req.Items)
{
var type = ParseType(item.Type)!.Value;
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
var categoryType = CategoryTypeFor(type, ParseTransferDirection(item.TransferDirection));
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != categoryType)
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
}
var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
var existing = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
var existing = new Dictionary<string, Transaction>();
foreach (var item in req.Items)
{
var found = await FindExistingTransactionAsync(
item.ClientRequestId.Trim(),
NormalizeOptional(item.Provider, 24),
NormalizeOptional(item.ProviderTransactionId, 128),
NormalizeOptional(item.RecognitionOccurrenceId, 64),
ct: ct);
if (found is not null) existing[item.CandidateId] = found;
}
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
var mapped = new List<(string CandidateId, Transaction Transaction)>();
foreach (var item in req.Items)
{
if (existing.TryGetValue(item.ClientRequestId, out var found))
if (existing.TryGetValue(item.CandidateId, out var found))
{
mapped.Add((item.CandidateId, found));
continue;
@@ -161,10 +200,17 @@ public class TransactionsController(
Amount = item.Amount,
Note = item.Note?.Trim(),
PaymentMethod = item.PaymentMethod?.Trim(),
TransferDirection = ParseTransferDirection(item.TransferDirection),
Counterparty = NormalizeOptional(item.Counterparty, 100),
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
Source = SourceFromWire(item.Source),
SourceText = item.SourceText,
ClientRequestId = item.ClientRequestId,
Provider = NormalizeOptional(item.Provider, 24),
ProviderTransactionId = NormalizeOptional(item.ProviderTransactionId, 128),
RecognitionOccurrenceId = NormalizeOptional(item.RecognitionOccurrenceId, 64),
EvidenceFingerprint = NormalizeOptional(item.EvidenceFingerprint, 64),
RecognitionConfidence = NormalizeOptional(item.RecognitionConfidence, 24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
@@ -175,8 +221,8 @@ public class TransactionsController(
{
await db.SaveChangesAsync(ct);
var expenseChanges = mapped
.Where(item => !existing.ContainsKey(item.Transaction.ClientRequestId ?? "") &&
item.Transaction.Type == TransactionType.Expense)
.Where(item => !existing.ContainsKey(item.CandidateId) &&
IsExpense(item.Transaction))
.Select(item => new BudgetExpenseChange(
item.Transaction.LedgerId,
item.Transaction.CategoryId,
@@ -198,17 +244,22 @@ public class TransactionsController(
{
entry.State = EntityState.Detached;
}
var raced = await db.Transactions
.AsNoTracking()
.Include(transaction => transaction.Category)
.Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId != null &&
requestIds.Contains(transaction.ClientRequestId))
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
if (raced.Count != requestIds.Count) throw;
return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
var raced = new List<RecognitionBatchTransactionDto>();
foreach (var item in req.Items)
{
var found = await FindExistingTransactionAsync(
item.ClientRequestId.Trim(),
NormalizeOptional(item.Provider, 24),
NormalizeOptional(item.ProviderTransactionId, 128),
NormalizeOptional(item.RecognitionOccurrenceId, 64),
asNoTracking: true,
ct: ct);
if (found is null) throw;
raced.Add(new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(found, found.Category)));
}
return Ok(raced);
}
}
@@ -228,12 +279,18 @@ public class TransactionsController(
if (req.Amount <= 0) return BadRequest(new ApiError("AMOUNT_INVALID", "金额必须大于 0"));
var type = ParseType(req.Type);
if (!type.HasValue)
return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expenseincome"));
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expenseincome 或 transfer"));
var transferDirection = ParseTransferDirection(req.TransferDirection);
if (type == TransactionType.Transfer && !transferDirection.HasValue)
return BadRequest(new ApiError("TRANSFER_DIRECTION_REQUIRED", "转账必须选择转入或转出"));
if (type != TransactionType.Transfer && !string.IsNullOrWhiteSpace(req.TransferDirection))
return BadRequest(new ApiError("TRANSFER_DIRECTION_INVALID", "非转账账单不能设置转账方向"));
var categoryType = CategoryTypeFor(type.Value, transferDirection);
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var category = await db.Categories.FirstOrDefaultAsync(c =>
c.Id == req.CategoryId && !c.IsDeleted && c.Type == type.Value &&
c.Id == req.CategoryId && !c.IsDeleted && c.Type == categoryType &&
(c.UserId == null || c.UserId == Uid));
if (category is null) return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
@@ -247,7 +304,7 @@ public class TransactionsController(
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
var expenseChanges = new List<BudgetExpenseChange>();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
tx.LedgerId = ledgerId.Value;
@@ -257,9 +314,11 @@ public class TransactionsController(
tx.Amount = req.Amount;
tx.Note = req.Note?.Trim();
tx.PaymentMethod = req.PaymentMethod?.Trim();
tx.TransferDirection = transferDirection;
tx.Counterparty = NormalizeOptional(req.Counterparty, 100);
tx.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
await using var writeScope = await db.Database.BeginTransactionAsync();
@@ -327,7 +386,7 @@ public class TransactionsController(
tx.UpdatedAt = DateTime.UtcNow;
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
@@ -378,16 +437,16 @@ public class TransactionsController(
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
var income = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var expense = list.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount);
var income = list.Where(IsIncome).Sum(t => t.Amount);
var expense = list.Where(IsExpense).Sum(t => t.Amount);
var days = list
.GroupBy(t => DateOnly.FromDateTime(ChinaClock.ToLocal(t.OccurredAt)))
.OrderByDescending(g => g.Key)
.Select(g => new DailyGroupDto(
g.Key,
g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount),
g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount),
g.Where(IsExpense).Sum(t => t.Amount),
g.Where(IsIncome).Sum(t => t.Amount),
g.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
@@ -413,9 +472,9 @@ public class TransactionsController(
t.OccurredAt >= start && t.OccurredAt < end)
.ToListAsync();
var expenses = list.Where(t => t.Type == TransactionType.Expense).ToList();
var expenses = list.Where(IsExpense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
var totalIncome = list.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var totalIncome = list.Where(IsIncome).Sum(t => t.Amount);
var byCat = expenses
.GroupBy(t => t.Category)
@@ -438,7 +497,9 @@ public class TransactionsController(
var (prevStart, _) = ChinaClock.MonthRangeUtc(prevYear, prevMonth);
var prevExpense = await db.Transactions
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.Type == TransactionType.Expense && t.OccurredAt >= prevStart && t.OccurredAt < start)
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer && t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= prevStart && t.OccurredAt < start)
.SumAsync(t => t.Amount);
var trend = prevExpense == 0 ? "这是你的第一个月记账哦"
: totalExpense > prevExpense * 1.05m ? $"比上月多花了 ¥{(totalExpense - prevExpense):F0}"
@@ -513,11 +574,11 @@ public class TransactionsController(
.ToListAsync();
var expenses = list
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(IsExpense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
.Where(transaction => transaction.Type == TransactionType.Income)
.Where(IsIncome)
.Sum(transaction => transaction.Amount);
var byCategory = expenses
.GroupBy(transaction => transaction.Category)
@@ -550,8 +611,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
$"{pointStart.Month}月",
pointStart,
pointItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
pointItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
pointItems.Where(IsExpense).Sum(item => item.Amount),
pointItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -570,8 +631,8 @@ public class TransactionsController(
return new PeriodTrendPointDto(
label,
date,
dayItems.Where(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
dayItems.Where(IsExpense).Sum(item => item.Amount),
dayItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
@@ -580,7 +641,9 @@ public class TransactionsController(
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= previousStart &&
transaction.OccurredAt < start)
.SumAsync(transaction => transaction.Amount);
@@ -634,9 +697,79 @@ public class TransactionsController(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => null,
};
private static TransferDirection? ParseTransferDirection(string? value) => value switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => null,
};
private static TransactionType CategoryTypeFor(
TransactionType type,
TransferDirection? direction) => type == TransactionType.Transfer
? direction == TransferDirection.In ? TransactionType.Income : TransactionType.Expense
: type;
internal static bool IsExpense(Transaction transaction) =>
transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out;
internal static bool IsIncome(Transaction transaction) =>
transaction.Type == TransactionType.Income ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.In;
private static string? NormalizeOptional(string? value, int maxLength)
{
var normalized = value?.Trim();
if (string.IsNullOrEmpty(normalized)) return null;
return normalized.Length <= maxLength ? normalized : normalized[..maxLength];
}
private async Task<Transaction?> FindExistingTransactionAsync(
string? clientRequestId,
string? provider,
string? providerTransactionId,
string? occurrenceId,
bool asNoTracking = false,
CancellationToken ct = default)
{
IQueryable<Transaction> Query() => db.Transactions
.IgnoreQueryFilters()
.Include(transaction => transaction.Category);
if (provider is not null && providerTransactionId is not null)
{
var providerMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.Provider == provider &&
transaction.ProviderTransactionId == providerTransactionId))
.FirstOrDefaultAsync(ct);
if (providerMatch is not null) return providerMatch;
}
if (occurrenceId is not null)
{
var occurrenceMatch = await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.RecognitionOccurrenceId == occurrenceId))
.FirstOrDefaultAsync(ct);
if (occurrenceMatch is not null) return occurrenceMatch;
}
if (clientRequestId is not null)
{
return await Track(Query().Where(transaction => transaction.UserId == Uid &&
transaction.ClientRequestId == clientRequestId))
.FirstOrDefaultAsync(ct);
}
return null;
IQueryable<Transaction> Track(IQueryable<Transaction> query) =>
asNoTracking ? query.AsNoTracking() : query;
}
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
{
if (!baseUpdatedAt.HasValue) return false;
@@ -662,7 +795,12 @@ public class TransactionsController(
internal static TransactionDto ToDto(Transaction t, Category c) => new(
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
t.Type == TransactionType.Income ? "income" : "expense",
t.Type switch
{
TransactionType.Income => "income",
TransactionType.Transfer => "transfer",
_ => "expense",
},
t.Amount,
t.Source == TransactionSource.AiChat
&& !string.IsNullOrWhiteSpace(t.SourceText)
@@ -685,5 +823,17 @@ public class TransactionsController(
t.SourceText,
t.IsDeleted,
c.ColorKey,
t.UpdatedAt);
t.UpdatedAt,
t.TransferDirection switch
{
TransferDirection.In => "in",
TransferDirection.Out => "out",
_ => null,
},
t.Counterparty,
t.Provider,
t.ProviderTransactionId,
t.RecognitionOccurrenceId,
t.EvidenceFingerprint,
t.RecognitionConfidence);
}
@@ -206,9 +206,12 @@ public class UsersController(
}),
transactions = transactions.Select(t => new
{
t.Id, t.LedgerId, t.CategoryId,
type = t.Type.ToString().ToLowerInvariant(),
t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
t.Id, t.LedgerId, t.CategoryId,
type = t.Type.ToString().ToLowerInvariant(),
transferDirection = t.TransferDirection.ToWire(),
t.Counterparty, t.Amount, t.Note, t.PaymentMethod, t.OccurredAt,
t.Provider, t.ProviderTransactionId, t.RecognitionOccurrenceId,
t.EvidenceFingerprint, t.RecognitionConfidence,
source = t.Source.ToString(), t.SourceText,
t.IsDeleted, t.DeletedAt, t.CreatedAt, t.UpdatedAt,
}),
@@ -225,14 +228,26 @@ public class UsersController(
var ledgerNames = ledgers.ToDictionary(l => l.Id, l => l.Name);
var transactionCsv = new StringBuilder(
"ID,账本,类型,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
"ID,账本,类型,转账方向,对方,金额,分类,备注,支付方式,发生时间,来源,已删除\r\n");
foreach (var tx in transactions)
{
transactionCsv.AppendJoin(',', new[]
{
Csv(tx.Id),
Csv(ledgerNames.GetValueOrDefault(tx.LedgerId, "")),
Csv(tx.Type == TransactionType.Income ? "收入" : "支出"),
Csv(tx.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer => "转账",
_ => "支出",
}),
Csv(tx.TransferDirection switch
{
TransferDirection.In => "转入",
TransferDirection.Out => "转出",
_ => "",
}),
Csv(tx.Counterparty),
Csv(tx.Amount),
Csv(tx.Category.Name),
Csv(tx.Note),