802 lines
30 KiB
C#
802 lines
30 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using MiaoJiZhang.Domain.Entities;
|
|
using MiaoJiZhang.Domain.Enums;
|
|
using MiaoJiZhang.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MiaoJiZhang.Api.Services;
|
|
|
|
public record AgentTurnResult(
|
|
string Reply,
|
|
IReadOnlyList<Transaction> Transactions);
|
|
|
|
public class AgentService(
|
|
AppDbContext db,
|
|
ILlmClient llm,
|
|
BudgetPushService budgetPush,
|
|
ILogger<AgentService> logger)
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions =
|
|
new(JsonSerializerDefaults.Web);
|
|
|
|
private static readonly object TransactionItemsSchema = Schema(
|
|
"""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"items": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": 10,
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"type": {
|
|
"type": "string",
|
|
"enum": ["expense", "income", "transfer"],
|
|
"description": "账单类型;明确转账时使用 transfer"
|
|
},
|
|
"amount": {
|
|
"type": "number",
|
|
"exclusiveMinimum": 0
|
|
},
|
|
"categoryName": {
|
|
"type": "string",
|
|
"description": "支出分类:餐饮、饮品、购物、交通、住房、娱乐、医疗、学习、服饰、人情、旅行、其他;收入分类:工资、奖金、理财、兼职、红包、报销、其他"
|
|
},
|
|
"note": {
|
|
"type": "string",
|
|
"description": "适合明细列表展示的简短备注,不要照抄用户整句话"
|
|
},
|
|
"paymentMethod": {
|
|
"type": "string",
|
|
"description": "可选,例如微信支付、支付宝、现金"
|
|
},
|
|
"transferDirection": {
|
|
"type": "string",
|
|
"enum": ["in", "out"],
|
|
"description": "type=transfer 时必填,转入为 in,转出为 out"
|
|
},
|
|
"counterparty": {
|
|
"type": "string",
|
|
"description": "转账对方,可选"
|
|
},
|
|
"occurredAt": {
|
|
"type": "string",
|
|
"description": "可选,ISO 8601 时间;未提时间不要填写"
|
|
}
|
|
},
|
|
"required": ["type", "amount", "categoryName", "note"],
|
|
"additionalProperties": false
|
|
}
|
|
}
|
|
},
|
|
"required": ["items"],
|
|
"additionalProperties": false
|
|
}
|
|
""");
|
|
|
|
private static readonly IReadOnlyList<AgentToolDefinition> ChatTools =
|
|
[
|
|
new(
|
|
"create_transactions",
|
|
"仅当每笔交易的金额和收入/支出方向都明确时,创建一笔或多笔账单。裸金额、方向冲突或不确定时不得调用,应先询问用户。",
|
|
TransactionItemsSchema),
|
|
new(
|
|
"get_financial_summary",
|
|
"读取用户默认账本在指定周期内的真实收入、支出、结余和分类汇总。",
|
|
Schema(
|
|
"""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"period": {
|
|
"type": "string",
|
|
"enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"]
|
|
}
|
|
},
|
|
"required": ["period"],
|
|
"additionalProperties": false
|
|
}
|
|
""")),
|
|
new(
|
|
"list_transactions",
|
|
"读取用户默认账本的近期真实账单,可按周期、收支方向和分类筛选。",
|
|
Schema(
|
|
"""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"period": {
|
|
"type": "string",
|
|
"enum": ["today", "yesterday", "this_week", "this_month", "last_month", "this_year"]
|
|
},
|
|
"type": {
|
|
"type": "string",
|
|
"enum": ["expense", "income", "transfer"]
|
|
},
|
|
"categoryName": {
|
|
"type": "string"
|
|
},
|
|
"limit": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 10
|
|
}
|
|
},
|
|
"required": ["period"],
|
|
"additionalProperties": false
|
|
}
|
|
""")),
|
|
new(
|
|
"get_budget_status",
|
|
"读取用户默认账本某月的总预算、分类预算、已花金额和周期预算状态。",
|
|
Schema(
|
|
"""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"year": {
|
|
"type": "integer",
|
|
"minimum": 2000,
|
|
"maximum": 2200
|
|
},
|
|
"month": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 12
|
|
}
|
|
},
|
|
"required": ["year", "month"],
|
|
"additionalProperties": false
|
|
}
|
|
""")),
|
|
];
|
|
|
|
private static readonly IReadOnlyList<AgentToolDefinition> DraftTools =
|
|
[
|
|
new(
|
|
"prepare_transactions",
|
|
"解析明确的自然语言交易草稿。金额或收支方向不明确时不要调用。",
|
|
TransactionItemsSchema),
|
|
];
|
|
|
|
public async Task<AgentTurnResult> RunAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
bool allowWrites,
|
|
string userText,
|
|
ChatMessage sourceMessage,
|
|
string personaPrompt,
|
|
string conversationContext,
|
|
Func<string, CancellationToken, Task>? onToken = null,
|
|
Func<string, CancellationToken, Task>? onToolRunning = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (!llm.IsEnabled)
|
|
throw new InvalidOperationException("AI 功能尚未配置");
|
|
if (sourceMessage.Id == 0)
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
var created = new List<Transaction>();
|
|
var writeExecuted = false;
|
|
var response = await llm.RunAgentAsync(
|
|
BuildSystemPrompt(personaPrompt, conversationContext),
|
|
userText,
|
|
ChatTools,
|
|
async (call, toolCt) =>
|
|
{
|
|
logger.LogInformation(
|
|
"Executing AI agent tool. user={UserId} tool={Tool} callId={CallId}",
|
|
userId,
|
|
call.Name,
|
|
call.CallId);
|
|
return call.Name switch
|
|
{
|
|
"create_transactions" when allowWrites => await CreateTransactionsAsync(
|
|
userId,
|
|
ledgerId,
|
|
userText,
|
|
sourceMessage,
|
|
call.Arguments,
|
|
created,
|
|
() => writeExecuted,
|
|
() => writeExecuted = true,
|
|
toolCt),
|
|
"create_transactions" => Serialize(new
|
|
{
|
|
ok = false,
|
|
error = "AI 自动入账已关闭,请在记一笔页面手动确认",
|
|
}),
|
|
"get_financial_summary" => await FinancialSummaryAsync(
|
|
userId,
|
|
ledgerId,
|
|
call.Arguments,
|
|
toolCt),
|
|
"list_transactions" => await ListTransactionsAsync(
|
|
userId,
|
|
ledgerId,
|
|
call.Arguments,
|
|
toolCt),
|
|
"get_budget_status" => await BudgetStatusAsync(
|
|
userId,
|
|
ledgerId,
|
|
call.Arguments,
|
|
toolCt),
|
|
_ => Serialize(new { ok = false, error = "未知工具" }),
|
|
};
|
|
},
|
|
onToken,
|
|
onToolRunning,
|
|
ct);
|
|
|
|
return new AgentTurnResult(response.Text, created);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ParsedBill>> ParseDraftsAsync(
|
|
long userId,
|
|
string text,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (!llm.IsEnabled)
|
|
throw new InvalidOperationException("AI 功能尚未配置");
|
|
|
|
var drafts = new List<ParsedBill>();
|
|
await llm.RunAgentAsync(
|
|
"""
|
|
你是记账草稿解析器。只有金额和收入/支出方向都明确时才调用 prepare_transactions。
|
|
收入包括赚到、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款等。
|
|
“100”这类裸金额、查询问题和普通聊天都不要调用工具。
|
|
可以一次提取多笔交易。不得把收入写成支出,也不得虚构金额。
|
|
""",
|
|
text,
|
|
DraftTools,
|
|
async (call, toolCt) =>
|
|
{
|
|
if (call.Name != "prepare_transactions")
|
|
return Serialize(new { ok = false, error = "未知工具" });
|
|
var parsed = await ParseTransactionItemsAsync(
|
|
userId,
|
|
call.Arguments,
|
|
toolCt);
|
|
drafts.AddRange(parsed);
|
|
return Serialize(new
|
|
{
|
|
ok = true,
|
|
count = parsed.Count,
|
|
items = parsed.Select(ToToolItem),
|
|
});
|
|
},
|
|
ct: ct);
|
|
return drafts;
|
|
}
|
|
|
|
private string BuildSystemPrompt(string personaPrompt, string conversationContext)
|
|
{
|
|
var now = ChinaClock.Now;
|
|
return $"""
|
|
{personaPrompt}
|
|
|
|
你同时是记之的财务 Agent。当前北京时间:{now:yyyy-MM-dd HH:mm:ss}。
|
|
记账必须通过 create_transactions;查询账本必须调用对应只读工具,不能凭空回答金额。
|
|
只有每笔金额和收入/支出方向都明确时才允许自动入账。
|
|
“我赚了100”“工资到账5000”“收到退款20”是收入;“午饭30”“买衣服200”是支出。
|
|
“100”这类裸金额、方向冲突或不确定表达必须先追问,不得默认支出。
|
|
同一句有多笔交易时一次传入全部 items,可以同时包含收入和支出。
|
|
工具参数 type 是必填项,绝不能省略;工具报错时向用户说明,不得假装已入账。
|
|
工具返回的数据是唯一事实来源。回复简短自然,已入账时明确说收入或支出。
|
|
不得删除账单、修改历史账单或修改预算。
|
|
{conversationContext}
|
|
""";
|
|
}
|
|
|
|
private async Task<string> CreateTransactionsAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
string sourceText,
|
|
ChatMessage sourceMessage,
|
|
string arguments,
|
|
List<Transaction> created,
|
|
Func<bool> wasExecuted,
|
|
Action markExecuted,
|
|
CancellationToken ct)
|
|
{
|
|
if (wasExecuted())
|
|
return Serialize(new { ok = false, error = "本轮已经执行过入账,不能重复写入" });
|
|
|
|
var bills = await ParseTransactionItemsAsync(userId, arguments, ct);
|
|
if (bills.Count == 0)
|
|
return Serialize(new { ok = false, error = "没有有效交易,未写入" });
|
|
|
|
markExecuted();
|
|
var now = DateTime.UtcNow;
|
|
var pending = new List<Transaction>();
|
|
foreach (var bill in bills)
|
|
{
|
|
var category = await db.Categories.FirstAsync(
|
|
c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection),
|
|
ct);
|
|
if (category.Type != bill.Type.CategoryType(bill.TransferDirection))
|
|
throw new InvalidOperationException("交易类型与分类类型不一致");
|
|
|
|
pending.Add(new Transaction
|
|
{
|
|
LedgerId = ledgerId,
|
|
UserId = userId,
|
|
CategoryId = category.Id,
|
|
Category = category,
|
|
Type = bill.Type,
|
|
TransferDirection = bill.TransferDirection,
|
|
Counterparty = bill.Counterparty,
|
|
Amount = bill.Amount,
|
|
Note = NormalizeNote(bill.Note, sourceText, category.Name),
|
|
PaymentMethod = bill.PaymentMethod,
|
|
OccurredAt = bill.OccurredAt ?? now,
|
|
Source = TransactionSource.AiChat,
|
|
SourceText = sourceText,
|
|
SourceChatMessageId = sourceMessage.Id,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
});
|
|
}
|
|
|
|
db.Transactions.AddRange(pending);
|
|
await using var writeScope = await db.Database.BeginTransactionAsync(ct);
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
await budgetPush.EvaluateAsync(userId, pending
|
|
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
|
|
.Select(transaction => new BudgetExpenseChange(
|
|
transaction.LedgerId,
|
|
transaction.CategoryId,
|
|
transaction.OccurredAt,
|
|
transaction.Amount)), ct);
|
|
await db.SaveChangesAsync(ct);
|
|
await writeScope.CommitAsync(ct);
|
|
}
|
|
catch
|
|
{
|
|
await writeScope.RollbackAsync(ct);
|
|
foreach (var transaction in pending)
|
|
db.Entry(transaction).State = EntityState.Detached;
|
|
throw;
|
|
}
|
|
created.AddRange(pending);
|
|
|
|
return Serialize(new
|
|
{
|
|
ok = true,
|
|
count = created.Count,
|
|
items = created.Select(transaction => new
|
|
{
|
|
id = transaction.Id,
|
|
type = transaction.Type.ToWire(),
|
|
transferDirection = transaction.TransferDirection.ToWire(),
|
|
transaction.Counterparty,
|
|
amount = transaction.Amount,
|
|
categoryName = transaction.Category.Name,
|
|
note = transaction.Note,
|
|
}),
|
|
});
|
|
}
|
|
|
|
private async Task<List<ParsedBill>> ParseTransactionItemsAsync(
|
|
long userId,
|
|
string arguments,
|
|
CancellationToken ct)
|
|
{
|
|
using var document = JsonDocument.Parse(arguments);
|
|
var root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object ||
|
|
!root.TryGetProperty("items", out var items) ||
|
|
items.ValueKind != JsonValueKind.Array)
|
|
{
|
|
throw new InvalidOperationException("工具参数缺少 items");
|
|
}
|
|
if (items.GetArrayLength() is < 1 or > 10)
|
|
throw new InvalidOperationException("每次只能处理 1 到 10 笔交易");
|
|
|
|
var bills = new List<ParsedBill>();
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
var typeText = RequiredString(item, "type").ToLowerInvariant();
|
|
var type = typeText switch
|
|
{
|
|
"income" => TransactionType.Income,
|
|
"expense" => TransactionType.Expense,
|
|
"transfer" => TransactionType.Transfer,
|
|
_ => throw new InvalidOperationException(
|
|
"交易 type 必须是 income、expense 或 transfer"),
|
|
};
|
|
TransferDirection? transferDirection = null;
|
|
if (type == TransactionType.Transfer)
|
|
{
|
|
transferDirection = RequiredString(item, "transferDirection") switch
|
|
{
|
|
"in" => TransferDirection.In,
|
|
"out" => TransferDirection.Out,
|
|
_ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
|
|
};
|
|
}
|
|
if (!item.TryGetProperty("amount", out var amountNode) ||
|
|
!amountNode.TryGetDecimal(out var amount) ||
|
|
amount <= 0)
|
|
{
|
|
throw new InvalidOperationException("交易金额必须大于 0");
|
|
}
|
|
|
|
var categoryName = RequiredString(item, "categoryName").Trim();
|
|
var note = RequiredString(item, "note").Trim();
|
|
if (categoryName.Length > 20) categoryName = categoryName[..20];
|
|
if (note.Length > 30) note = note[..30];
|
|
var category = await ResolveCategoryAsync(
|
|
userId,
|
|
type.CategoryType(transferDirection),
|
|
categoryName,
|
|
ct);
|
|
|
|
string? paymentMethod = null;
|
|
if (item.TryGetProperty("paymentMethod", out var paymentNode) &&
|
|
paymentNode.ValueKind == JsonValueKind.String)
|
|
{
|
|
paymentMethod = paymentNode.GetString()?.Trim();
|
|
if (paymentMethod?.Length > 20)
|
|
paymentMethod = paymentMethod[..20];
|
|
}
|
|
|
|
DateTime? occurredAt = null;
|
|
if (item.TryGetProperty("occurredAt", out var occurredNode) &&
|
|
occurredNode.ValueKind == JsonValueKind.String &&
|
|
!string.IsNullOrWhiteSpace(occurredNode.GetString()))
|
|
{
|
|
if (!DateTimeOffset.TryParse(
|
|
occurredNode.GetString(),
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeLocal,
|
|
out var parsedTime))
|
|
{
|
|
throw new InvalidOperationException("交易时间格式无效");
|
|
}
|
|
occurredAt = parsedTime.UtcDateTime;
|
|
}
|
|
|
|
bills.Add(new ParsedBill(
|
|
type,
|
|
category.Id,
|
|
category.Name,
|
|
string.IsNullOrWhiteSpace(note) ? category.Name : note,
|
|
amount,
|
|
paymentMethod,
|
|
occurredAt,
|
|
transferDirection,
|
|
OptionalString(item, "counterparty", 100)));
|
|
}
|
|
return bills;
|
|
}
|
|
|
|
private async Task<string> FinancialSummaryAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
string arguments,
|
|
CancellationToken ct)
|
|
{
|
|
using var document = JsonDocument.Parse(arguments);
|
|
var period = RequiredString(document.RootElement, "period");
|
|
var (start, end, label) = ResolvePeriod(period);
|
|
var transactions = await db.Transactions
|
|
.Include(t => t.Category)
|
|
.Where(t => t.UserId == userId &&
|
|
t.LedgerId == ledgerId &&
|
|
t.OccurredAt >= start &&
|
|
t.OccurredAt < end)
|
|
.ToListAsync(ct);
|
|
|
|
var income = transactions
|
|
.Where(t => t.Type.IsIncome(t.TransferDirection))
|
|
.Sum(t => t.Amount);
|
|
var expense = transactions
|
|
.Where(t => t.Type.IsExpense(t.TransferDirection))
|
|
.Sum(t => t.Amount);
|
|
var categories = transactions
|
|
.GroupBy(t => new
|
|
{
|
|
EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense",
|
|
t.Category.Name,
|
|
})
|
|
.Select(group => new
|
|
{
|
|
type = group.Key.EffectiveType,
|
|
categoryName = group.Key.Name,
|
|
amount = group.Sum(t => t.Amount),
|
|
})
|
|
.OrderByDescending(item => item.amount)
|
|
.ToList();
|
|
|
|
return Serialize(new
|
|
{
|
|
ok = true,
|
|
period = label,
|
|
income,
|
|
expense,
|
|
balance = income - expense,
|
|
count = transactions.Count,
|
|
categories,
|
|
});
|
|
}
|
|
|
|
private async Task<string> ListTransactionsAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
string arguments,
|
|
CancellationToken ct)
|
|
{
|
|
using var document = JsonDocument.Parse(arguments);
|
|
var root = document.RootElement;
|
|
var period = RequiredString(root, "period");
|
|
var (start, end, label) = ResolvePeriod(period);
|
|
var query = db.Transactions
|
|
.Include(t => t.Category)
|
|
.Where(t => t.UserId == userId &&
|
|
t.LedgerId == ledgerId &&
|
|
t.OccurredAt >= start &&
|
|
t.OccurredAt < end);
|
|
|
|
if (root.TryGetProperty("type", out var typeNode) &&
|
|
typeNode.ValueKind == JsonValueKind.String)
|
|
{
|
|
var type = typeNode.GetString() switch
|
|
{
|
|
"income" => TransactionType.Income,
|
|
"expense" => TransactionType.Expense,
|
|
"transfer" => TransactionType.Transfer,
|
|
_ => throw new InvalidOperationException("筛选类型无效"),
|
|
};
|
|
query = query.Where(t => t.Type == type);
|
|
}
|
|
if (root.TryGetProperty("categoryName", out var categoryNode) &&
|
|
categoryNode.ValueKind == JsonValueKind.String &&
|
|
!string.IsNullOrWhiteSpace(categoryNode.GetString()))
|
|
{
|
|
var categoryName = categoryNode.GetString()!.Trim();
|
|
query = query.Where(t => t.Category.Name == categoryName);
|
|
}
|
|
var limit = root.TryGetProperty("limit", out var limitNode) &&
|
|
limitNode.TryGetInt32(out var requestedLimit)
|
|
? Math.Clamp(requestedLimit, 1, 10)
|
|
: 10;
|
|
var transactions = await query
|
|
.OrderByDescending(t => t.OccurredAt)
|
|
.Take(limit)
|
|
.ToListAsync(ct);
|
|
|
|
return Serialize(new
|
|
{
|
|
ok = true,
|
|
period = label,
|
|
count = transactions.Count,
|
|
items = transactions.Select(transaction => new
|
|
{
|
|
id = transaction.Id,
|
|
type = transaction.Type.ToWire(),
|
|
transferDirection = transaction.TransferDirection.ToWire(),
|
|
transaction.Counterparty,
|
|
transaction.Amount,
|
|
categoryName = transaction.Category.Name,
|
|
transaction.Note,
|
|
occurredAt = transaction.OccurredAt,
|
|
}),
|
|
});
|
|
}
|
|
|
|
private async Task<string> BudgetStatusAsync(
|
|
long userId,
|
|
long ledgerId,
|
|
string arguments,
|
|
CancellationToken ct)
|
|
{
|
|
using var document = JsonDocument.Parse(arguments);
|
|
var root = document.RootElement;
|
|
if (!root.TryGetProperty("year", out var yearNode) ||
|
|
!yearNode.TryGetInt32(out var year) ||
|
|
!root.TryGetProperty("month", out var monthNode) ||
|
|
!monthNode.TryGetInt32(out var month) ||
|
|
year is < 2000 or > 2200 ||
|
|
month is < 1 or > 12)
|
|
{
|
|
throw new InvalidOperationException("预算年月无效");
|
|
}
|
|
|
|
var period = year * 100 + month;
|
|
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
|
|
var budgetRows = await db.Budgets
|
|
.Where(b => b.UserId == userId &&
|
|
b.LedgerId == ledgerId &&
|
|
(b.Period == period || b.Period == 0))
|
|
.ToListAsync(ct);
|
|
var effective = budgetRows
|
|
.GroupBy(b => b.CategoryId)
|
|
.Select(group => group
|
|
.OrderByDescending(b => b.Period == period)
|
|
.First())
|
|
.ToList();
|
|
var categoryIds = effective
|
|
.Where(budget => budget.CategoryId.HasValue)
|
|
.Select(budget => budget.CategoryId!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
var categoryNames = await db.Categories
|
|
.Where(category => categoryIds.Contains(category.Id))
|
|
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
|
|
var expenses = await db.Transactions
|
|
.Where(t => t.UserId == userId &&
|
|
t.LedgerId == ledgerId &&
|
|
(t.Type == TransactionType.Expense ||
|
|
t.Type == TransactionType.Transfer &&
|
|
t.TransferDirection == TransferDirection.Out) &&
|
|
t.OccurredAt >= start &&
|
|
t.OccurredAt < end)
|
|
.Select(t => new { t.CategoryId, t.Amount })
|
|
.ToListAsync(ct);
|
|
var spentByCategory = expenses
|
|
.GroupBy(t => t.CategoryId)
|
|
.ToDictionary(group => group.Key, group => group.Sum(t => t.Amount));
|
|
var totalSpent = expenses.Sum(t => t.Amount);
|
|
|
|
return Serialize(new
|
|
{
|
|
ok = true,
|
|
year,
|
|
month,
|
|
totalSpent,
|
|
budgets = effective.Select(budget => new
|
|
{
|
|
categoryName = budget.CategoryId.HasValue
|
|
? categoryNames.GetValueOrDefault(budget.CategoryId.Value, "其他")
|
|
: "总预算",
|
|
amount = budget.Amount,
|
|
spent = budget.CategoryId.HasValue
|
|
? spentByCategory.GetValueOrDefault(budget.CategoryId.Value)
|
|
: totalSpent,
|
|
remaining = budget.Amount - (
|
|
budget.CategoryId.HasValue
|
|
? spentByCategory.GetValueOrDefault(budget.CategoryId.Value)
|
|
: totalSpent),
|
|
recurring = budget.Period == 0,
|
|
}),
|
|
});
|
|
}
|
|
|
|
private static string NormalizeNote(
|
|
string note,
|
|
string sourceText,
|
|
string categoryName)
|
|
{
|
|
var trimmed = note.Trim();
|
|
var comparableNote = string.Concat(trimmed.Where(char.IsLetterOrDigit));
|
|
var comparableSource = string.Concat(
|
|
sourceText.Trim().Where(char.IsLetterOrDigit));
|
|
if (string.Equals(
|
|
comparableNote,
|
|
comparableSource,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return categoryName;
|
|
}
|
|
return trimmed.Length > 16 ? trimmed[..16] : trimmed;
|
|
}
|
|
|
|
private async Task<Category> ResolveCategoryAsync(
|
|
long userId,
|
|
TransactionType type,
|
|
string requestedName,
|
|
CancellationToken ct)
|
|
{
|
|
var category = await db.Categories
|
|
.Where(c => !c.IsDeleted &&
|
|
c.Type == type &&
|
|
c.Name == requestedName &&
|
|
(c.UserId == null || c.UserId == userId))
|
|
.OrderByDescending(c => c.UserId == userId)
|
|
.FirstOrDefaultAsync(ct);
|
|
return category ?? await db.Categories.FirstAsync(
|
|
c => !c.IsDeleted &&
|
|
c.UserId == null &&
|
|
c.Type == type &&
|
|
c.Name == "其他",
|
|
ct);
|
|
}
|
|
|
|
private async Task<long> DefaultLedgerIdAsync(long userId, CancellationToken ct) =>
|
|
await db.Ledgers
|
|
.Where(l => l.OwnerId == userId && l.IsDefault)
|
|
.Select(l => l.Id)
|
|
.FirstAsync(ct);
|
|
|
|
private static object ToToolItem(ParsedBill bill) => new
|
|
{
|
|
type = bill.Type.ToWire(),
|
|
transferDirection = bill.TransferDirection.ToWire(),
|
|
bill.Counterparty,
|
|
bill.Amount,
|
|
bill.CategoryName,
|
|
bill.Note,
|
|
bill.PaymentMethod,
|
|
occurredAt = bill.OccurredAt,
|
|
};
|
|
|
|
private static string RequiredString(JsonElement root, string name)
|
|
{
|
|
if (!root.TryGetProperty(name, out var node) ||
|
|
node.ValueKind != JsonValueKind.String ||
|
|
string.IsNullOrWhiteSpace(node.GetString()))
|
|
{
|
|
throw new InvalidOperationException($"工具参数缺少 {name}");
|
|
}
|
|
return node.GetString()!;
|
|
}
|
|
|
|
private static string? OptionalString(JsonElement root, string name, int maxLength)
|
|
{
|
|
if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String)
|
|
return null;
|
|
var value = node.GetString()?.Trim();
|
|
if (string.IsNullOrEmpty(value)) return null;
|
|
return value.Length <= maxLength ? value : value[..maxLength];
|
|
}
|
|
|
|
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
|
|
string period)
|
|
{
|
|
var localNow = ChinaClock.Now;
|
|
var today = localNow.Date;
|
|
DateTime startLocal;
|
|
DateTime endLocal;
|
|
string label;
|
|
switch (period)
|
|
{
|
|
case "today":
|
|
startLocal = today;
|
|
endLocal = today.AddDays(1);
|
|
label = "今天";
|
|
break;
|
|
case "yesterday":
|
|
startLocal = today.AddDays(-1);
|
|
endLocal = today;
|
|
label = "昨天";
|
|
break;
|
|
case "this_week":
|
|
var offset = ((int)today.DayOfWeek + 6) % 7;
|
|
startLocal = today.AddDays(-offset);
|
|
endLocal = startLocal.AddDays(7);
|
|
label = "本周";
|
|
break;
|
|
case "this_month":
|
|
startLocal = new DateTime(today.Year, today.Month, 1);
|
|
endLocal = startLocal.AddMonths(1);
|
|
label = "本月";
|
|
break;
|
|
case "last_month":
|
|
endLocal = new DateTime(today.Year, today.Month, 1);
|
|
startLocal = endLocal.AddMonths(-1);
|
|
label = "上月";
|
|
break;
|
|
case "this_year":
|
|
startLocal = new DateTime(today.Year, 1, 1);
|
|
endLocal = startLocal.AddYears(1);
|
|
label = "今年";
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException("查询周期无效");
|
|
}
|
|
return (ChinaClock.ToUtc(startLocal), ChinaClock.ToUtc(endLocal), label);
|
|
}
|
|
|
|
private static object Schema(string json) =>
|
|
JsonSerializer.Deserialize<JsonElement>(json);
|
|
|
|
private static string Serialize(object value) =>
|
|
JsonSerializer.Serialize(value, JsonOptions);
|
|
}
|