using System.Security.Claims;
using MiaoJiZhang.Api.Contracts;
using MiaoJiZhang.Api.Services;
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/transactions")]
public class TransactionsController(
AppDbContext db,
LedgerResolver ledgers,
BudgetPushService budgetPush) : ControllerBase
{
private long Uid => long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")!);
/// 手动记一笔
[HttpPost]
public async Task> Create(CreateTransactionRequest req)
{
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 或 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 (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 FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
}
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var cat = await db.Categories.FirstOrDefaultAsync(c =>
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", "分类与收支类型不一致"));
var tx = new Transaction
{
LedgerId = ledgerId.Value,
UserId = Uid,
CategoryId = cat.Id,
OccurredAt = req.OccurredAt.HasValue
? NormalizeOccurredAt(req.OccurredAt.Value)
: DateTime.UtcNow,
Type = type.Value,
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,
};
db.Transactions.Add(tx);
await using var writeScope = await db.Database.BeginTransactionAsync();
try
{
await db.SaveChangesAsync();
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
await db.SaveChangesAsync();
}
await writeScope.CommitAsync();
return Ok(ToDto(tx, cat));
}
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 FindExistingTransactionAsync(
clientRequestId,
provider,
providerTransactionId,
occurrenceId,
asNoTracking: true);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
}
[HttpPost("recognition-batch")]
public async Task>> CreateRecognitionBatch(
CreateRecognitionBatchRequest req,
CancellationToken ct)
{
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.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) == 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", "批次中存在无效账单"));
}
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
if (!ledgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var categoryIds = req.Items.Select(item => item.CategoryId).Distinct().ToList();
var categories = await db.Categories
.Where(category => categoryIds.Contains(category.Id) && !category.IsDeleted &&
(category.UserId == null || category.UserId == Uid))
.ToDictionaryAsync(category => category.Id, ct);
foreach (var item in req.Items)
{
var type = ParseType(item.Type)!.Value;
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 existing = new Dictionary();
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.CandidateId, out var found))
{
mapped.Add((item.CandidateId, found));
continue;
}
var category = categories[item.CategoryId];
var transaction = new Transaction
{
LedgerId = ledgerId.Value,
UserId = Uid,
CategoryId = category.Id,
Category = category,
Type = ParseType(item.Type)!.Value,
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,
};
db.Transactions.Add(transaction);
mapped.Add((item.CandidateId, transaction));
}
try
{
await db.SaveChangesAsync(ct);
var expenseChanges = mapped
.Where(item => !existing.ContainsKey(item.CandidateId) &&
IsExpense(item.Transaction))
.Select(item => new BudgetExpenseChange(
item.Transaction.LedgerId,
item.Transaction.CategoryId,
item.Transaction.OccurredAt,
item.Transaction.Amount))
.ToList();
await budgetPush.EvaluateAsync(Uid, expenseChanges, ct);
await db.SaveChangesAsync(ct);
await transactionScope.CommitAsync(ct);
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
item.CandidateId,
ToDto(item.Transaction, item.Transaction.Category))).ToList());
}
catch (DbUpdateException)
{
await transactionScope.RollbackAsync(ct);
foreach (var entry in db.ChangeTracker.Entries()
.Where(entry => entry.State == EntityState.Added))
{
entry.State = EntityState.Detached;
}
var raced = new List();
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);
}
}
/// 账单详情(P11:AI 来源追溯、分类、备注、时间)
[HttpGet("{id:long}")]
public async Task> Detail(long id)
{
var tx = await db.Transactions.Include(t => t.Category)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
return Ok(ToDto(tx, tx.Category));
}
[HttpPut("{id:long}")]
public async Task> Update(long id, UpdateTransactionRequest req)
{
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 或 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 == 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);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
if (HasVersionConflict(tx, req.BaseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请选择保留本地或云端版本",
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? category),
});
var expenseChanges = new List();
if (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, -tx.Amount));
tx.LedgerId = ledgerId.Value;
tx.CategoryId = category.Id;
tx.Category = category;
tx.Type = type.Value;
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 (IsExpense(tx))
expenseChanges.Add(new BudgetExpenseChange(
tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount));
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
await budgetPush.EvaluateAsync(Uid, expenseChanges);
await db.SaveChangesAsync();
await writeScope.CommitAsync();
return Ok(ToDto(tx, category));
}
/// 撤销/删除(软删除,决策 18)
[HttpDelete("{id:long}")]
public async Task Delete(long id, [FromQuery] DateTime? baseUpdatedAt = null)
{
var tx = await db.Transactions.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "账单不存在"));
if (HasVersionConflict(tx, baseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请确认后再删除",
server = ToDto(tx, await db.Categories.FindAsync(tx.CategoryId) ?? tx.Category),
}); tx.IsDeleted = true;
tx.DeletedAt = DateTime.UtcNow;
tx.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("recycle-bin")]
public async Task>> RecycleBin(
[FromQuery] long? ledgerId = null,
[FromQuery] long? beforeId = null,
[FromQuery] int limit = 50)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var query = db.Transactions
.IgnoreQueryFilters()
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted);
if (beforeId.HasValue) query = query.Where(t => t.Id < beforeId.Value);
var items = await query.OrderByDescending(t => t.DeletedAt)
.ThenByDescending(t => t.Id)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync();
return Ok(items.Select(t => ToDto(t, t.Category)).ToList());
}
[HttpPost("{id:long}/restore")]
public async Task> Restore(long id, [FromQuery] DateTime? baseUpdatedAt = null)
{
var tx = await db.Transactions.IgnoreQueryFilters().Include(t => t.Category)
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
if (HasVersionConflict(tx, baseUpdatedAt))
return Conflict(new
{
code = "SYNC_CONFLICT",
message = "账单已在其他设备修改,请确认后再恢复",
server = ToDto(tx, tx.Category),
}); tx.IsDeleted = false;
tx.DeletedAt = null;
tx.UpdatedAt = DateTime.UtcNow;
await using var writeScope = await db.Database.BeginTransactionAsync();
await db.SaveChangesAsync();
if (IsExpense(tx))
{
await budgetPush.EvaluateAsync(Uid,
[new BudgetExpenseChange(tx.LedgerId, tx.CategoryId, tx.OccurredAt, tx.Amount)]);
await db.SaveChangesAsync();
}
await writeScope.CommitAsync();
return Ok(ToDto(tx, tx.Category));
}
[HttpDelete("{id:long}/permanent")]
public async Task PermanentDelete(long id)
{
var tx = await db.Transactions.IgnoreQueryFilters()
.FirstOrDefaultAsync(t => t.Id == id && t.UserId == Uid && t.IsDeleted);
if (tx is null) return NotFound(new ApiError("TX_NOT_FOUND", "回收站中没有这笔账单"));
await PermanentDeleteCore(tx);
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("recycle-bin")]
public async Task ClearRecycleBin([FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var items = await db.Transactions.IgnoreQueryFilters()
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value && t.IsDeleted)
.ToListAsync();
foreach (var item in items) await PermanentDeleteCore(item);
await db.SaveChangesAsync();
return NoContent();
}
/// 月账单(按日分组,首页明细用)
[HttpGet("month")]
public async Task> Month([FromQuery] int year, [FromQuery] int month, [FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var q = db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.OccurredAt >= start && t.OccurredAt < end);
var list = await q.OrderByDescending(t => t.OccurredAt).ToListAsync();
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(IsExpense).Sum(t => t.Amount),
g.Where(IsIncome).Sum(t => t.Amount),
g.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
return Ok(new MonthSummaryDto(year, month, income, expense, income - expense, list.Count, days));
}
/// 月统计(统计页:分类占比 + 每日趋势 + AI 分析)
[HttpGet("stats")]
public async Task> Stats(
[FromQuery] int year,
[FromQuery] int month,
[FromQuery] long? ledgerId = null)
{
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var daysInMonth = DateTime.DaysInMonth(year, month);
var list = await db.Transactions
.Include(t => t.Category)
.Where(t => t.UserId == Uid && t.LedgerId == resolvedLedgerId.Value &&
t.OccurredAt >= start && t.OccurredAt < end)
.ToListAsync();
var expenses = list.Where(IsExpense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
var totalIncome = list.Where(IsIncome).Sum(t => t.Amount);
var byCat = expenses
.GroupBy(t => t.Category)
.Select(g => new CategoryStatDto(
g.Key.Id, g.Key.Name, g.Key.IconKey,
g.Sum(t => t.Amount),
totalExpense == 0 ? 0 : (double)(g.Sum(t => t.Amount) / totalExpense * 100),
g.Key.ColorKey))
.OrderByDescending(c => c.Amount)
.ToList();
var daily = Enumerable.Range(1, daysInMonth)
.Select(d => expenses.Where(t => ChinaClock.ToLocal(t.OccurredAt).Day == d).Sum(t => t.Amount))
.ToList();
// AI 分析卡文案
var topCat = byCat.FirstOrDefault();
var prevMonth = month == 1 ? 12 : month - 1;
var prevYear = month == 1 ? year - 1 : year;
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.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}"
: totalExpense < prevExpense * 0.95m ? $"比上月省了 ¥{(prevExpense - totalExpense):F0}" : "和上月差不多";
var aiText = topCat is null
? "这个月还没开始花钱呢,快去记一笔吧"
: $"这个月「{topCat.Name}」花最多,共 ¥{topCat.Amount:F0}(占 {topCat.Percent:F0}%),{trend}。";
return Ok(new MonthStatsDto(year, month, totalExpense, totalIncome, byCat, daily) { AiAnalysis = aiText });
}
[HttpGet("stats/period")]
public async Task> PeriodStats(
[FromQuery] string period = "month",
[FromQuery] DateTime? anchor = null,
[FromQuery] long? ledgerId = null)
{
var normalizedPeriod = period.Trim().ToLowerInvariant();
if (normalizedPeriod is not ("week" or "month" or "year"))
return BadRequest(new ApiError(
"PERIOD_INVALID",
"统计周期必须是 week、month 或 year"));
var resolvedLedgerId = await ledgers.ResolveAsync(Uid, ledgerId);
if (!resolvedLedgerId.HasValue)
return BadRequest(new ApiError(
"LEDGER_NOT_FOUND",
"账本不存在或无权访问"));
var selected = DateTime.SpecifyKind(
(anchor ?? ChinaClock.Now).Date,
DateTimeKind.Unspecified);
DateTime localStart;
DateTime localEnd;
DateTime previousLocalStart;
string periodLabel;
switch (normalizedPeriod)
{
case "week":
{
var offset = ((int)selected.DayOfWeek + 6) % 7;
localStart = selected.AddDays(-offset);
localEnd = localStart.AddDays(7);
previousLocalStart = localStart.AddDays(-7);
periodLabel = $"{localStart:MM月dd日} - {localEnd.AddDays(-1):MM月dd日}";
break;
}
case "year":
localStart = new DateTime(selected.Year, 1, 1);
localEnd = localStart.AddYears(1);
previousLocalStart = localStart.AddYears(-1);
periodLabel = $"{localStart.Year}年";
break;
default:
localStart = new DateTime(selected.Year, selected.Month, 1);
localEnd = localStart.AddMonths(1);
previousLocalStart = localStart.AddMonths(-1);
periodLabel = $"{localStart.Year}年{localStart.Month}月";
break;
}
var start = ChinaClock.ToUtc(localStart);
var end = ChinaClock.ToUtc(localEnd);
var previousStart = ChinaClock.ToUtc(previousLocalStart);
var list = await db.Transactions
.Include(transaction => transaction.Category)
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.OccurredAt >= start &&
transaction.OccurredAt < end)
.ToListAsync();
var expenses = list
.Where(IsExpense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
.Where(IsIncome)
.Sum(transaction => transaction.Amount);
var byCategory = expenses
.GroupBy(transaction => transaction.Category)
.Select(group => new CategoryStatDto(
group.Key.Id,
group.Key.Name,
group.Key.IconKey,
group.Sum(transaction => transaction.Amount),
totalExpense == 0
? 0
: (double)(group.Sum(transaction => transaction.Amount) / totalExpense * 100),
group.Key.ColorKey))
.OrderByDescending(item => item.Amount)
.ThenBy(item => item.Name)
.ToList();
List trend;
if (normalizedPeriod == "year")
{
trend = Enumerable.Range(0, 12)
.Select(index =>
{
var pointStart = localStart.AddMonths(index);
var pointEnd = pointStart.AddMonths(1);
var pointItems = list.Where(transaction =>
{
var local = ChinaClock.ToLocal(transaction.OccurredAt);
return local >= pointStart && local < pointEnd;
});
return new PeriodTrendPointDto(
$"{pointStart.Month}月",
pointStart,
pointItems.Where(IsExpense).Sum(item => item.Amount),
pointItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
else
{
var dayCount = (localEnd - localStart).Days;
trend = Enumerable.Range(0, dayCount)
.Select(index =>
{
var date = localStart.AddDays(index);
var dayItems = list.Where(transaction =>
ChinaClock.ToLocal(transaction.OccurredAt).Date == date.Date);
var label = normalizedPeriod == "week"
? new[] { "周一", "周二", "周三", "周四", "周五", "周六", "周日" }[index]
: $"{date.Day}日";
return new PeriodTrendPointDto(
label,
date,
dayItems.Where(IsExpense).Sum(item => item.Amount),
dayItems.Where(IsIncome).Sum(item => item.Amount));
})
.ToList();
}
var previousExpense = await db.Transactions
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= previousStart &&
transaction.OccurredAt < start)
.SumAsync(transaction => transaction.Amount);
var topCategory = byCategory.FirstOrDefault();
string analysis;
if (topCategory is null)
{
analysis = "这个周期还没有支出记录";
}
else if (previousExpense == 0)
{
analysis = $"{topCategory.Name}支出最多,共 ¥{topCategory.Amount:F0}";
}
else
{
var difference = totalExpense - previousExpense;
analysis = Math.Abs(difference) < previousExpense * 0.05m
? $"{topCategory.Name}支出最多,与上个周期基本持平"
: difference > 0
? $"{topCategory.Name}支出最多,比上个周期多 ¥{difference:F0}"
: $"{topCategory.Name}支出最多,比上个周期少 ¥{-difference:F0}";
}
return Ok(new PeriodStatsDto(
normalizedPeriod,
periodLabel,
localStart,
localEnd,
totalExpense,
totalIncome,
totalIncome - totalExpense,
list.Count,
byCategory,
trend,
analysis));
}
private async Task PermanentDeleteCore(Transaction transaction)
{
var cards = await db.ChatMessages
.Where(message => message.TransactionId == transaction.Id)
.ToListAsync();
foreach (var card in cards)
{
card.TransactionId = null;
card.Content = """{"deleted":true}""";
}
db.Transactions.Remove(transaction);
}
private static TransactionType? ParseType(string? value) => value switch
{
"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 FindExistingTransactionAsync(
string? clientRequestId,
string? provider,
string? providerTransactionId,
string? occurrenceId,
bool asNoTracking = false,
CancellationToken ct = default)
{
IQueryable 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 Track(IQueryable query) =>
asNoTracking ? query.AsNoTracking() : query;
}
private static bool HasVersionConflict(Transaction transaction, DateTime? baseUpdatedAt)
{
if (!baseUpdatedAt.HasValue) return false;
var baseline = baseUpdatedAt.Value.Kind == DateTimeKind.Utc
? baseUpdatedAt.Value
: baseUpdatedAt.Value.ToUniversalTime();
return transaction.UpdatedAt > baseline.AddMilliseconds(1);
}
private static TransactionSource SourceFromWire(string? source) => source switch
{
"voice" => TransactionSource.Voice,
"ocr" => TransactionSource.ReceiptOcr,
"screenshot" => TransactionSource.Screenshot,
"accessibility" => TransactionSource.Accessibility,
"notification" => TransactionSource.Notification,
"recognition_ai" => TransactionSource.RecognitionAi,
"local_ocr" => TransactionSource.LocalOcr,
_ => TransactionSource.Manual,
};
private static DateTime NormalizeOccurredAt(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
internal static TransactionDto ToDto(Transaction t, Category c) => new(
t.Id, t.LedgerId, c.Id, c.Name, c.IconKey,
t.Type switch
{
TransactionType.Income => "income",
TransactionType.Transfer => "transfer",
_ => "expense",
},
t.Amount,
t.Source == TransactionSource.AiChat
&& !string.IsNullOrWhiteSpace(t.SourceText)
&& string.Equals(t.Note?.Trim(), t.SourceText.Trim(), StringComparison.Ordinal)
? TransactionNoteFormatter.BuildNote(t.SourceText, c.Name)
: t.Note,
t.PaymentMethod, t.OccurredAt,
t.Source switch
{
TransactionSource.AiChat => "ai_chat",
TransactionSource.Voice => "voice",
TransactionSource.ReceiptOcr => "ocr",
TransactionSource.Screenshot => "screenshot",
TransactionSource.Accessibility => "accessibility",
TransactionSource.Notification => "notification",
TransactionSource.RecognitionAi => "recognition_ai",
TransactionSource.LocalOcr => "local_ocr",
_ => "manual",
},
t.SourceText,
t.IsDeleted,
c.ColorKey,
t.UpdatedAt,
t.TransferDirection switch
{
TransferDirection.In => "in",
TransferDirection.Out => "out",
_ => null,
},
t.Counterparty,
t.Provider,
t.ProviderTransactionId,
t.RecognitionOccurrenceId,
t.EvidenceFingerprint,
t.RecognitionConfidence);
}