Add transfer tracking and secure admin access
This commit is contained in:
@@ -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", "收支类型必须是 expense 或 income"));
|
||||
if (type is not null && type is not ("income" or "expense" or "transfer"))
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 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", "收支类型必须是 expense 或 income"));
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 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", "收支类型必须是 expense 或 income"));
|
||||
return BadRequest(new ApiError("TYPE_INVALID", "账单类型必须是 expense、income 或 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),
|
||||
|
||||
Reference in New Issue
Block a user