Files
jizhi/backend/MiaoJiZhang.Api/Controllers/TransactionsController.cs
T

690 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")!);
/// <summary>手动记一笔</summary>
[HttpPost]
public async Task<ActionResult<TransactionDto>> 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"));
var clientRequestId = string.IsNullOrWhiteSpace(req.ClientRequestId)
? null
: req.ClientRequestId.Trim();
if (clientRequestId?.Length > 64)
return BadRequest(new ApiError("CLIENT_REQUEST_ID_INVALID", "幂等标识最长 64 个字符"));
if (clientRequestId != null)
{
var existing = await db.Transactions
.Include(t => t.Category)
.FirstOrDefaultAsync(t =>
t.UserId == Uid && t.ClientRequestId == clientRequestId);
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 == type.Value &&
(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,
Source = SourceFromWire(req.Source),
SourceText = req.SourceText,
ClientRequestId = clientRequestId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
db.Transactions.Add(tx);
await using var writeScope = await db.Database.BeginTransactionAsync();
try
{
await db.SaveChangesAsync();
if (tx.Type == TransactionType.Expense)
{
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)
{
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);
if (existing is not null) return Ok(ToDto(existing, existing.Category));
throw;
}
}
[HttpPost("recognition-batch")]
public async Task<ActionResult<List<RecognitionBatchTransactionDto>>> 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)
{
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))
{
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;
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
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);
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))
{
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(),
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
Source = SourceFromWire(item.Source),
SourceText = item.SourceText,
ClientRequestId = item.ClientRequestId,
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.Transaction.ClientRequestId ?? "") &&
item.Transaction.Type == TransactionType.Expense)
.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<Transaction>()
.Where(entry => entry.State == EntityState.Added))
{
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());
}
}
/// <summary>账单详情(P11:AI 来源追溯、分类、备注、时间)</summary>
[HttpGet("{id:long}")]
public async Task<ActionResult<TransactionDto>> 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<ActionResult<TransactionDto>> 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"));
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.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<BudgetExpenseChange>();
if (tx.Type == TransactionType.Expense)
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.OccurredAt = NormalizeOccurredAt(req.OccurredAt);
tx.UpdatedAt = DateTime.UtcNow;
if (tx.Type == TransactionType.Expense)
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));
}
/// <summary>撤销/删除(软删除,决策 18</summary>
[HttpDelete("{id:long}")]
public async Task<IActionResult> 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<ActionResult<List<TransactionDto>>> 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<ActionResult<TransactionDto>> 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 (tx.Type == TransactionType.Expense)
{
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<IActionResult> 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<IActionResult> 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();
}
/// <summary>月账单(按日分组,首页明细用)</summary>
[HttpGet("month")]
public async Task<ActionResult<MonthSummaryDto>> 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(t => t.Type == TransactionType.Income).Sum(t => t.Amount);
var expense = list.Where(t => t.Type == TransactionType.Expense).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.Select(t => ToDto(t, t.Category)).ToList()))
.ToList();
return Ok(new MonthSummaryDto(year, month, income, expense, income - expense, list.Count, days));
}
/// <summary>月统计(统计页:分类占比 + 每日趋势 + AI 分析)</summary>
[HttpGet("stats")]
public async Task<ActionResult<MonthStatsDto>> 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(t => t.Type == TransactionType.Expense).ToList();
var totalExpense = expenses.Sum(t => t.Amount);
var totalIncome = list.Where(t => t.Type == TransactionType.Income).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.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<ActionResult<PeriodStatsDto>> 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(transaction => transaction.Type == TransactionType.Expense)
.ToList();
var totalExpense = expenses.Sum(transaction => transaction.Amount);
var totalIncome = list
.Where(transaction => transaction.Type == TransactionType.Income)
.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<PeriodTrendPointDto> 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(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
pointItems.Where(item => item.Type == TransactionType.Income).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(item => item.Type == TransactionType.Expense).Sum(item => item.Amount),
dayItems.Where(item => item.Type == TransactionType.Income).Sum(item => item.Amount));
})
.ToList();
}
var previousExpense = await db.Transactions
.Where(transaction =>
transaction.UserId == Uid &&
transaction.LedgerId == resolvedLedgerId.Value &&
transaction.Type == TransactionType.Expense &&
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,
_ => null,
};
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 == TransactionType.Income ? "income" : "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);
}