648 lines
29 KiB
C#
648 lines
29 KiB
C#
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) : 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);
|
||
try
|
||
{
|
||
await db.SaveChangesAsync();
|
||
return Ok(ToDto(tx, cat));
|
||
}
|
||
catch (DbUpdateException) when (clientRequestId is not null)
|
||
{
|
||
// 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);
|
||
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),
|
||
});
|
||
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;
|
||
await db.SaveChangesAsync();
|
||
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 db.SaveChangesAsync();
|
||
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);
|
||
}
|