Initial project import
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public class AccountClosureCleanupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AccountClosureCleanupService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
await Cleanup(stoppingToken);
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await Cleanup(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task Cleanup(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var eraser =
|
||||
scope.ServiceProvider.GetRequiredService<AccountDataEraser>();
|
||||
var ids = await db.Users
|
||||
.Where(user =>
|
||||
user.AccountClosureScheduledAt.HasValue &&
|
||||
user.AccountClosureScheduledAt <= DateTime.UtcNow)
|
||||
.Select(user => user.Id)
|
||||
.ToListAsync(ct);
|
||||
foreach (var userId in ids)
|
||||
{
|
||||
await eraser.EraseAsync(userId, ct);
|
||||
logger.LogInformation(
|
||||
"Permanently removed account {UserId} after closure grace period",
|
||||
userId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Account closure cleanup failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public class AccountDataEraser(AppDbContext db)
|
||||
{
|
||||
public async Task EraseAsync(long userId, CancellationToken ct = default)
|
||||
{
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(ct);
|
||||
await db.ChatMessages
|
||||
.Where(message => message.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.Transactions
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.Budgets
|
||||
.Where(item => item.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.Categories
|
||||
.Where(item => item.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.AiCompanionSettings
|
||||
.Where(item => item.UserId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.Ledgers
|
||||
.Where(item => item.OwnerId == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await db.Users
|
||||
.Where(item => item.Id == userId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台鉴权:请求头 X-Admin-Key 与 appsettings.Admin:Key 匹配即可。
|
||||
/// 仅内部使用,不依赖 JWT/用户体系。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AdminAuthAttribute : Attribute, IAuthorizationFilter
|
||||
{
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
|
||||
var key = config["Admin:Key"];
|
||||
if (string.IsNullOrWhiteSpace(key) ||
|
||||
!context.HttpContext.Request.Headers.TryGetValue("X-Admin-Key", out var provided) ||
|
||||
provided != key)
|
||||
{
|
||||
context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
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,
|
||||
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"],
|
||||
"description": "交易方向,收入必须是 income,支出必须是 expense"
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"categoryName": {
|
||||
"type": "string",
|
||||
"description": "支出分类:餐饮、饮品、购物、交通、住房、娱乐、医疗、学习、服饰、人情、旅行、其他;收入分类:工资、奖金、理财、兼职、红包、报销、其他"
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "适合明细列表展示的简短备注,不要照抄用户整句话"
|
||||
},
|
||||
"paymentMethod": {
|
||||
"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"]
|
||||
},
|
||||
"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,
|
||||
ct);
|
||||
if (category.Type != bill.Type)
|
||||
throw new InvalidOperationException("交易类型与分类类型不一致");
|
||||
|
||||
pending.Add(new Transaction
|
||||
{
|
||||
LedgerId = ledgerId,
|
||||
UserId = userId,
|
||||
CategoryId = category.Id,
|
||||
Category = category,
|
||||
Type = bill.Type,
|
||||
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);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
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 == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
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,
|
||||
_ => throw new InvalidOperationException(
|
||||
"交易 type 必须是 income 或 expense"),
|
||||
};
|
||||
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,
|
||||
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));
|
||||
}
|
||||
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 == TransactionType.Income)
|
||||
.Sum(t => t.Amount);
|
||||
var expense = transactions
|
||||
.Where(t => t.Type == TransactionType.Expense)
|
||||
.Sum(t => t.Amount);
|
||||
var categories = transactions
|
||||
.GroupBy(t => new { t.Type, t.Category.Name })
|
||||
.Select(group => new
|
||||
{
|
||||
type = group.Key.Type == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
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,
|
||||
_ => 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 == TransactionType.Income
|
||||
? "income"
|
||||
: "expense",
|
||||
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.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 == TransactionType.Income ? "income" : "expense",
|
||||
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 (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);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Data;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed record AiChatQuotaStatus(
|
||||
int Limit,
|
||||
int Used,
|
||||
int Remaining,
|
||||
AiQuotaPeriod Period,
|
||||
DateTime WindowStartedAt,
|
||||
DateTime ResetAt,
|
||||
bool Allowed);
|
||||
|
||||
public sealed class AiChatQuotaService(AppDbContext db)
|
||||
{
|
||||
public async Task<AiChatQuotaStatus> TryConsumeAsync(
|
||||
long userId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
ct);
|
||||
var user = await db.Users.SingleAsync(item => item.Id == userId, ct);
|
||||
var status = GetStatus(user);
|
||||
if (!status.Allowed)
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
return status;
|
||||
}
|
||||
|
||||
if (user.AiChatWindowStartedAt != status.WindowStartedAt)
|
||||
{
|
||||
user.AiChatWindowStartedAt = status.WindowStartedAt;
|
||||
user.AiChatUsed = 0;
|
||||
}
|
||||
user.AiChatUsed++;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return GetStatus(user) with { Allowed = true };
|
||||
}
|
||||
|
||||
public static AiChatQuotaStatus GetStatus(User user)
|
||||
{
|
||||
var (start, end) = CurrentWindow(user.AiChatPeriod);
|
||||
var used = user.AiChatWindowStartedAt == start
|
||||
? Math.Max(0, user.AiChatUsed)
|
||||
: 0;
|
||||
var limit = Math.Max(0, user.AiChatLimit);
|
||||
var remaining = limit == 0 ? -1 : Math.Max(0, limit - used);
|
||||
return new AiChatQuotaStatus(
|
||||
limit,
|
||||
used,
|
||||
remaining,
|
||||
user.AiChatPeriod,
|
||||
start,
|
||||
end,
|
||||
limit == 0 || used < limit);
|
||||
}
|
||||
|
||||
public static (DateTime Start, DateTime End) CurrentWindow(
|
||||
AiQuotaPeriod period)
|
||||
{
|
||||
var now = ChinaClock.Now;
|
||||
var start = period switch
|
||||
{
|
||||
AiQuotaPeriod.Week => now.Date.AddDays(-(now.DayOfWeek == DayOfWeek.Sunday
|
||||
? 6
|
||||
: (int)now.DayOfWeek - 1)),
|
||||
AiQuotaPeriod.Month => new DateTime(now.Year, now.Month, 1),
|
||||
_ => now.Date,
|
||||
};
|
||||
var end = period switch
|
||||
{
|
||||
AiQuotaPeriod.Week => start.AddDays(7),
|
||||
AiQuotaPeriod.Month => start.AddMonths(1),
|
||||
_ => start.AddDays(1),
|
||||
};
|
||||
return (ChinaClock.ToUtc(start), ChinaClock.ToUtc(end));
|
||||
}
|
||||
|
||||
public static bool TryParsePeriod(string? value, out AiQuotaPeriod period)
|
||||
{
|
||||
period = value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"week" => AiQuotaPeriod.Week,
|
||||
"month" => AiQuotaPeriod.Month,
|
||||
_ => AiQuotaPeriod.Day,
|
||||
};
|
||||
return value?.Trim().ToLowerInvariant() is "day" or "week" or "month";
|
||||
}
|
||||
|
||||
public static string PeriodKey(AiQuotaPeriod period) =>
|
||||
period.ToString().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Security.Claims;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class FeaturePermissionKeys
|
||||
{
|
||||
public const string Ai = "ai";
|
||||
}
|
||||
|
||||
public sealed class AiPermissionService(AppDbContext db)
|
||||
{
|
||||
public async Task<bool> IsEnabledAsync(long userId, CancellationToken ct = default)
|
||||
{
|
||||
var value = await db.UserFeaturePermissions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && x.PermissionKey == FeaturePermissionKeys.Ai)
|
||||
.Select(x => (bool?)x.IsEnabled)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// Existing installations remain compatible if a migration has not backfilled yet.
|
||||
return value ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RequireAiPermissionAttribute : TypeFilterAttribute
|
||||
{
|
||||
public RequireAiPermissionAttribute() : base(typeof(AiPermissionFilter))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AiPermissionFilter(AiPermissionService permissions) : IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(
|
||||
ActionExecutingContext context,
|
||||
ActionExecutionDelegate next)
|
||||
{
|
||||
var value = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? context.HttpContext.User.FindFirstValue("sub");
|
||||
if (!long.TryParse(value, out var userId) ||
|
||||
!await permissions.IsEnabledAsync(userId, context.HttpContext.RequestAborted))
|
||||
{
|
||||
context.Result = new ObjectResult(new ApiError(
|
||||
"AI_PERMISSION_DENIED",
|
||||
"当前账号暂无 AI 功能权限"))
|
||||
{
|
||||
StatusCode = StatusCodes.Status403Forbidden,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public record ParsedBill(
|
||||
TransactionType Type,
|
||||
long CategoryId,
|
||||
string CategoryName,
|
||||
string Note,
|
||||
decimal Amount,
|
||||
string? PaymentMethod,
|
||||
DateTime? OccurredAt = null);
|
||||
|
||||
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
|
||||
|
||||
/// <summary>
|
||||
/// Cleans legacy AI notes for list display only. It never decides intent, type, or category.
|
||||
/// </summary>
|
||||
public static class TransactionNoteFormatter
|
||||
{
|
||||
private static readonly Regex AmountRegex = new(
|
||||
@"(?<!\d)(\d+(?:\.\d{1,2})?)\s*(?:元|块钱|块|rmb|人民币)?",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static string BuildNote(string sourceText, string fallback)
|
||||
{
|
||||
var note = AmountRegex.Replace(sourceText, " ");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"(?:微信|支付宝|现金)(?:支付|付款|收款)?",
|
||||
" ");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"^\s*(?:(?:今天|昨天|刚才|刚刚|我|我们)\s*)+",
|
||||
"");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"^\s*(?:在)?(?:淘宝|京东|拼多多|美团|饿了么)\s*",
|
||||
"");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"^\s*(?:赚了?|赚到|挣了?|挣到|收到|获得|领到|收入|入账|进账|到账|发了?|发放|报销了?|退款了?|退回|返现)\s*",
|
||||
"");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"^\s*(?:买了|买|花了|花|付了|支付了|消费了|吃了|吃|喝了|喝)\s*",
|
||||
"");
|
||||
note = Regex.Replace(
|
||||
note,
|
||||
@"\s*(?:花了|花|用了|付了|支付了|消费了|赚了|挣了|到账|入账)\s*$",
|
||||
"");
|
||||
note = Regex.Replace(note, @"[,。!?,.!?::;\s]+", " ").Trim();
|
||||
if (string.IsNullOrWhiteSpace(note)) note = fallback;
|
||||
return note.Length > 20 ? note[..20] : note;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// AI 回复文案生成。当前为模板版(性格 × 口癖),后续接 LLM。
|
||||
/// 性格模板与口癖均来自数据库(后台可配 —— 决策:Prompt 后台可调)。
|
||||
/// </summary>
|
||||
public class ReplyService(AppDbContext db)
|
||||
{
|
||||
public async Task<string> BillReplyAsync(long userId, ParsedBill bill)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
var action = bill.Type == TransactionType.Income ? "收入" : "支出";
|
||||
var body = persona switch
|
||||
{
|
||||
"gentle" => $"{action}记好啦~{bill.CategoryName} ¥{bill.Amount:F2}",
|
||||
"strict" => $"已记录{action}:{bill.CategoryName} ¥{bill.Amount:F2}。",
|
||||
"meme" => $"{action}记上了!{bill.CategoryName} ¥{bill.Amount:F2},家人们谁懂啊",
|
||||
_ => bill.Type == TransactionType.Income
|
||||
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
|
||||
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
|
||||
};
|
||||
return ApplyTic(body, persona, tic);
|
||||
}
|
||||
public async Task<string> QueryReplyAsync(long userId, decimal topAmount, string topCategory, double percent)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
var body = persona switch
|
||||
{
|
||||
"gentle" => $"这个月「{topCategory}」花得最多,¥{topAmount:F0}(占 {percent:F0}%)~ 要不要我帮你定个小目标呀?",
|
||||
"strict" => $"本月最大支出分类:{topCategory} ¥{topAmount:F2},占总支出 {percent:F0}%。建议设置分类预算。",
|
||||
"meme" => $"必须是「{topCategory}」!¥{topAmount:F0},占了 {percent:F0}%,你这钱包是碎钞机吧 2333",
|
||||
_ => $"是「{topCategory}」¥{topAmount:F0}(占 {percent:F0}%)!继续这么花,月底吃土",
|
||||
};
|
||||
return ApplyTic(body, persona, tic);
|
||||
}
|
||||
|
||||
public async Task<string> ChatReplyAsync(long userId)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
var body = persona == "gentle"
|
||||
? "嗯嗯我在听~要记账直接说金额就行哦"
|
||||
: "在听。想记账就直接说,比如「午饭 26」";
|
||||
return ApplyTic(body, persona, tic);
|
||||
}
|
||||
|
||||
public Task<string> MonthlyRoastAsync(
|
||||
long userId,
|
||||
decimal income,
|
||||
decimal expense,
|
||||
string topCategory,
|
||||
decimal topAmount) =>
|
||||
PeriodRoastAsync(userId, "这个月", income, expense, topCategory, topAmount);
|
||||
|
||||
public async Task<string> PeriodRoastAsync(
|
||||
long userId,
|
||||
string periodLabel,
|
||||
decimal income,
|
||||
decimal expense,
|
||||
string topCategory,
|
||||
decimal topAmount)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
var ratio = income == 0 ? "N/A" : $"{expense / income * 100:F0}%";
|
||||
var body = persona switch
|
||||
{
|
||||
"gentle" => $"{periodLabel}收入 ¥{income:F0}、支出 ¥{expense:F0},「{topCategory}」花了 ¥{topAmount:F0}。我们一起把结余慢慢提高一点吧。",
|
||||
"strict" => $"{periodLabel}收支比 {ratio}。最大支出分类「{topCategory}」¥{topAmount:F2},建议继续按预算复盘。",
|
||||
"meme" => $"{periodLabel}收入 ¥{income:F0},支出 ¥{expense:F0},「{topCategory}」就干掉 ¥{topAmount:F0},钱包这波压力不小。",
|
||||
_ => $"{periodLabel}收入 ¥{income:F0},「{topCategory}」花了 ¥{topAmount:F0}。下个周期这个分类我会继续帮你盯着。",
|
||||
};
|
||||
return ApplyTic(body, persona, tic);
|
||||
}
|
||||
/// <summary>表情包回复(按性格;用户发表情包时调用)</summary>
|
||||
public async Task<(string Text, string? StickerKey)> StickerReplyAsync(long userId, string stickerKey)
|
||||
{
|
||||
var (persona, tic) = await GetPersonaAsync(userId);
|
||||
if (stickerKey == "salary")
|
||||
{
|
||||
var t = persona == "strict" ? "检测到收入信号。请报具体金额,我来记录。" : ApplyTic("哦?发工资了?多少多少,快报数,我帮你记收入!", persona, tic);
|
||||
return (t, null);
|
||||
}
|
||||
var text = persona switch
|
||||
{
|
||||
"gentle" => "哈哈收到~心情好最重要啦",
|
||||
"strict" => "已收到。请继续保持记账习惯。",
|
||||
"meme" => "哈哈哈哈这表情包我先存了",
|
||||
_ => ApplyTic("卖萌也没用!该省还得省", persona, tic),
|
||||
};
|
||||
// 回一个表情包(毒舌回生气,温柔回开心)
|
||||
var replySticker = persona == "gentle" ? "happy" : "angry";
|
||||
return (text, replySticker);
|
||||
}
|
||||
|
||||
private async Task<(string Persona, string Tic)> GetPersonaAsync(long userId)
|
||||
{
|
||||
var setting = await db.AiCompanionSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
var personaKey = setting?.PersonaKey ?? "sassy_cat";
|
||||
var avatarKey = setting?.AvatarKey ?? "cat";
|
||||
var tic = await db.AiAvatars.Where(a => a.Key == avatarKey)
|
||||
.Select(a => a.SpeechTic).FirstOrDefaultAsync() ?? "";
|
||||
return (personaKey, tic);
|
||||
}
|
||||
|
||||
/// <summary>口癖跟随形象(决策 20);严格管家不用口癖</summary>
|
||||
private static string ApplyTic(string text, string persona, string tic)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tic) || persona == "strict") return text;
|
||||
return text + tic;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class BudgetRecommendationService(
|
||||
AppDbContext db,
|
||||
ILlmClient llm,
|
||||
ILogger<BudgetRecommendationService> logger)
|
||||
{
|
||||
private static readonly AgentToolDefinition AdjustBudgetTool = new(
|
||||
"adjust_budget_draft",
|
||||
"根据用户追加指令调整当前预算草稿。必须返回完整预算草稿,不能直接写入数据库。",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestedTotal": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"categoryId": { "type": "integer" },
|
||||
"amount": { "type": "number", "minimum": 0 }
|
||||
},
|
||||
"required": ["categoryId", "amount"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "一句话说明本次调整了什么"
|
||||
}
|
||||
},
|
||||
"required": ["suggestedTotal", "items", "summary"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
"""));
|
||||
|
||||
public async Task<BudgetRecommendationResponse> RefineAsync(
|
||||
long userId,
|
||||
long ledgerId,
|
||||
RefineBudgetRecommendationRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!llm.IsEnabled)
|
||||
throw new InvalidOperationException("AI 预算调整尚未配置");
|
||||
|
||||
var instruction = request.Instruction.Trim();
|
||||
if (instruction.Length == 0)
|
||||
throw new ArgumentException("请输入预算调整要求");
|
||||
if (instruction.Length > 500)
|
||||
throw new ArgumentException("单次调整要求不能超过 500 个字符");
|
||||
if (request.CurrentDraft.Items.Count > 50)
|
||||
throw new ArgumentException("预算分类数量过多");
|
||||
|
||||
var contexts = await LoadCategoryContextsAsync(
|
||||
userId,
|
||||
ledgerId,
|
||||
request.Year,
|
||||
request.Month,
|
||||
ct);
|
||||
var (currentStart, currentEnd) =
|
||||
ChinaClock.MonthRangeUtc(request.Year, request.Month);
|
||||
var currentSpentTotal = await db.Transactions
|
||||
.Where(transaction =>
|
||||
transaction.UserId == userId &&
|
||||
transaction.LedgerId == ledgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
transaction.OccurredAt >= currentStart &&
|
||||
transaction.OccurredAt < currentEnd)
|
||||
.SumAsync(transaction => transaction.Amount, ct);
|
||||
if (contexts.Count == 0)
|
||||
throw new ArgumentException("没有可用于预算的支出分类");
|
||||
|
||||
var currentItems = request.CurrentDraft.Items
|
||||
.Where(item => contexts.ContainsKey(item.CategoryId))
|
||||
.GroupBy(item => item.CategoryId)
|
||||
.ToDictionary(group => group.Key, group => Math.Max(0, group.Last().Amount));
|
||||
|
||||
BudgetRecommendationResponse? validated = null;
|
||||
string? toolSummary = null;
|
||||
var response = await llm.RunAgentAsync(
|
||||
BuildSystemPrompt(request, contexts, currentItems, currentSpentTotal),
|
||||
instruction,
|
||||
[AdjustBudgetTool],
|
||||
(call, _) =>
|
||||
{
|
||||
if (call.Name != AdjustBudgetTool.Name)
|
||||
return Task.FromResult(JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
error = "未知预算工具",
|
||||
}));
|
||||
|
||||
try
|
||||
{
|
||||
validated = ValidateToolDraft(
|
||||
request,
|
||||
contexts,
|
||||
currentItems,
|
||||
currentSpentTotal,
|
||||
call.Arguments,
|
||||
out toolSummary);
|
||||
return Task.FromResult(JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = true,
|
||||
draft = new
|
||||
{
|
||||
suggestedTotal = validated.SuggestedTotal,
|
||||
items = validated.Items.Select(item => new
|
||||
{
|
||||
categoryId = item.CategoryId,
|
||||
amount = item.SuggestedAmount,
|
||||
}),
|
||||
},
|
||||
warnings = validated.Warnings,
|
||||
}));
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is JsonException or ArgumentException)
|
||||
{
|
||||
return Task.FromResult(JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
error = exception.Message,
|
||||
}));
|
||||
}
|
||||
},
|
||||
ct: ct);
|
||||
|
||||
if (validated is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Budget refinement completed without tool call. user={UserId} ledger={LedgerId}",
|
||||
userId,
|
||||
ledgerId);
|
||||
throw new InvalidOperationException("AI 没有生成可用的预算调整草稿");
|
||||
}
|
||||
|
||||
var summary = string.IsNullOrWhiteSpace(response.Text)
|
||||
? toolSummary ?? "已根据你的要求调整预算草稿"
|
||||
: response.Text.Trim();
|
||||
return validated with { AdjustmentSummary = summary };
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, CategoryContext>> LoadCategoryContextsAsync(
|
||||
long userId,
|
||||
long ledgerId,
|
||||
int year,
|
||||
int month,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var (currentStart, currentEnd) = ChinaClock.MonthRangeUtc(year, month);
|
||||
var historyStart = currentStart.AddMonths(-3);
|
||||
var categories = await db.Categories
|
||||
.Where(category =>
|
||||
!category.IsDeleted &&
|
||||
category.Type == TransactionType.Expense &&
|
||||
(category.UserId == null || category.UserId == userId))
|
||||
.Select(category => new
|
||||
{
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
category.ColorKey,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
var transactions = await db.Transactions
|
||||
.Where(transaction =>
|
||||
transaction.UserId == userId &&
|
||||
transaction.LedgerId == ledgerId &&
|
||||
transaction.Type == TransactionType.Expense &&
|
||||
transaction.OccurredAt >= historyStart &&
|
||||
transaction.OccurredAt < currentEnd)
|
||||
.Select(transaction => new
|
||||
{
|
||||
transaction.CategoryId,
|
||||
transaction.Amount,
|
||||
transaction.OccurredAt,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return categories.ToDictionary(category => category.Id, category =>
|
||||
{
|
||||
var currentSpent = transactions
|
||||
.Where(transaction =>
|
||||
transaction.CategoryId == category.Id &&
|
||||
transaction.OccurredAt >= currentStart)
|
||||
.Sum(transaction => transaction.Amount);
|
||||
var history = Enumerable.Range(1, 3)
|
||||
.Select(offset =>
|
||||
{
|
||||
var start = currentStart.AddMonths(-offset);
|
||||
var end = start.AddMonths(1);
|
||||
return transactions
|
||||
.Where(transaction =>
|
||||
transaction.CategoryId == category.Id &&
|
||||
transaction.OccurredAt >= start &&
|
||||
transaction.OccurredAt < end)
|
||||
.Sum(transaction => transaction.Amount);
|
||||
})
|
||||
.Where(amount => amount > 0)
|
||||
.ToList();
|
||||
return new CategoryContext(
|
||||
category.Id,
|
||||
category.Name,
|
||||
category.IconKey,
|
||||
category.ColorKey,
|
||||
currentSpent,
|
||||
history.Count == 0 ? 0 : history.Average(),
|
||||
history.Count >= 3 ? "high" : history.Count > 0 ? "medium" : "low");
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildSystemPrompt(
|
||||
RefineBudgetRecommendationRequest request,
|
||||
IReadOnlyDictionary<long, CategoryContext> contexts,
|
||||
IReadOnlyDictionary<long, decimal> currentItems,
|
||||
decimal currentSpentTotal)
|
||||
{
|
||||
var history = (request.History ?? [])
|
||||
.Where(turn => turn.Role is "user" or "assistant")
|
||||
.TakeLast(12)
|
||||
.Select(turn => new
|
||||
{
|
||||
role = turn.Role,
|
||||
content = turn.Content.Length <= 500
|
||||
? turn.Content
|
||||
: turn.Content[..500],
|
||||
});
|
||||
var payload = new
|
||||
{
|
||||
year = request.Year,
|
||||
month = request.Month,
|
||||
currentDraft = new
|
||||
{
|
||||
suggestedTotal = request.CurrentDraft.SuggestedTotal,
|
||||
items = currentItems.Select(item => new
|
||||
{
|
||||
categoryId = item.Key,
|
||||
amount = item.Value,
|
||||
}),
|
||||
},
|
||||
allowedCategories = contexts.Values.Select(context => new
|
||||
{
|
||||
categoryId = context.Id,
|
||||
name = context.Name,
|
||||
currentSpent = context.CurrentSpent,
|
||||
historicalAverage = context.HistoricalAverage,
|
||||
}),
|
||||
currentSpentTotal,
|
||||
recentConversation = history,
|
||||
};
|
||||
return """
|
||||
你是预算草稿调整 Agent。根据用户的最新指令和给定真实数据,必须调用 adjust_budget_draft。
|
||||
工具参数必须返回完整草稿;没有要求改变的分类保持不变。只能使用 allowedCategories 中的 categoryId。
|
||||
分类金额不得低于 currentSpent,总预算不得低于所有分类预算之和,也不得低于本月总支出。
|
||||
如果用户要求违反约束,给出最接近的有效草稿,并在 summary 中说明。
|
||||
工具成功后只回复一句简短、自然的调整说明,不要声称预算已经保存。
|
||||
|
||||
财务上下文:
|
||||
""" + JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
internal static BudgetRecommendationResponse ValidateToolDraft(
|
||||
RefineBudgetRecommendationRequest request,
|
||||
IReadOnlyDictionary<long, CategoryContext> contexts,
|
||||
IReadOnlyDictionary<long, decimal> currentItems,
|
||||
decimal currentSpentTotal,
|
||||
string arguments,
|
||||
out string summary)
|
||||
{
|
||||
using var document = JsonDocument.Parse(arguments);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("items", out var itemsNode) ||
|
||||
itemsNode.ValueKind != JsonValueKind.Array)
|
||||
throw new ArgumentException("AI 返回的预算分类无效");
|
||||
|
||||
summary = root.TryGetProperty("summary", out var summaryNode)
|
||||
? summaryNode.GetString()?.Trim() ?? ""
|
||||
: "";
|
||||
var warnings = new List<string>();
|
||||
var proposed = new Dictionary<long, decimal>();
|
||||
foreach (var item in itemsNode.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("categoryId", out var categoryNode) ||
|
||||
!categoryNode.TryGetInt64(out var categoryId) ||
|
||||
!contexts.TryGetValue(categoryId, out var context))
|
||||
{
|
||||
warnings.Add("已忽略不存在或不可用的分类");
|
||||
continue;
|
||||
}
|
||||
if (!item.TryGetProperty("amount", out var amountNode) ||
|
||||
!amountNode.TryGetDecimal(out var amount))
|
||||
{
|
||||
warnings.Add($"“{context.Name}”金额无效,已保留原值");
|
||||
continue;
|
||||
}
|
||||
amount = Math.Max(0, decimal.Round(amount, 2));
|
||||
if (amount < context.CurrentSpent)
|
||||
{
|
||||
amount = context.CurrentSpent;
|
||||
warnings.Add($"“{context.Name}”不能低于已花 ¥{context.CurrentSpent:0.##}");
|
||||
}
|
||||
proposed[categoryId] = amount;
|
||||
}
|
||||
|
||||
foreach (var current in currentItems)
|
||||
{
|
||||
if (!proposed.ContainsKey(current.Key))
|
||||
{
|
||||
proposed[current.Key] = current.Value;
|
||||
warnings.Add($"“{contexts[current.Key].Name}”未明确调整,已保留原值");
|
||||
}
|
||||
}
|
||||
if (proposed.Count == 0)
|
||||
throw new ArgumentException("AI 没有返回可用的分类预算");
|
||||
|
||||
var requestedTotal =
|
||||
root.TryGetProperty("suggestedTotal", out var totalNode) &&
|
||||
totalNode.TryGetDecimal(out var parsedTotal)
|
||||
? Math.Max(0, decimal.Round(parsedTotal, 2))
|
||||
: Math.Max(0, request.CurrentDraft.SuggestedTotal);
|
||||
var categoryTotal = proposed.Values.Sum();
|
||||
var minimumTotal = Math.Max(categoryTotal, currentSpentTotal);
|
||||
if (requestedTotal < minimumTotal)
|
||||
{
|
||||
requestedTotal = minimumTotal;
|
||||
warnings.Add($"总预算不能低于分类合计或本月已花,已调整为 ¥{minimumTotal:0.##}");
|
||||
}
|
||||
|
||||
var recommendationItems = proposed
|
||||
.Where(item => item.Value > 0)
|
||||
.Select(item =>
|
||||
{
|
||||
var context = contexts[item.Key];
|
||||
return new BudgetRecommendationItemDto(
|
||||
context.Id,
|
||||
context.Name,
|
||||
context.IconKey,
|
||||
item.Value,
|
||||
context.CurrentSpent,
|
||||
context.HistoricalAverage,
|
||||
context.Confidence,
|
||||
context.ColorKey);
|
||||
})
|
||||
.OrderByDescending(item => item.SuggestedAmount)
|
||||
.ToList();
|
||||
return new BudgetRecommendationResponse(
|
||||
request.Year,
|
||||
request.Month,
|
||||
requestedTotal,
|
||||
"预算草稿已调整,确认应用前不会写入正式预算。",
|
||||
recommendationItems,
|
||||
summary,
|
||||
warnings.Distinct().ToList());
|
||||
}
|
||||
|
||||
internal sealed record CategoryContext(
|
||||
long Id,
|
||||
string Name,
|
||||
string IconKey,
|
||||
string ColorKey,
|
||||
decimal CurrentSpent,
|
||||
decimal HistoricalAverage,
|
||||
string Confidence);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class ChinaClock
|
||||
{
|
||||
private static readonly Lazy<TimeZoneInfo> Zone = new(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Shanghai");
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
|
||||
}
|
||||
});
|
||||
|
||||
public static DateTime Now => ToLocal(DateTime.UtcNow);
|
||||
|
||||
public static DateTime ToLocal(DateTime utc) =>
|
||||
TimeZoneInfo.ConvertTimeFromUtc(
|
||||
DateTime.SpecifyKind(utc, DateTimeKind.Utc),
|
||||
Zone.Value);
|
||||
|
||||
public static DateTime ToUtc(DateTime local) =>
|
||||
TimeZoneInfo.ConvertTimeToUtc(
|
||||
DateTime.SpecifyKind(local, DateTimeKind.Unspecified),
|
||||
Zone.Value);
|
||||
|
||||
public static (DateTime Start, DateTime End) WeekRangeUtc(DateTime localDate)
|
||||
{
|
||||
var date = DateTime.SpecifyKind(localDate.Date, DateTimeKind.Unspecified);
|
||||
var offset = ((int)date.DayOfWeek + 6) % 7;
|
||||
var start = date.AddDays(-offset);
|
||||
return (ToUtc(start), ToUtc(start.AddDays(7)));
|
||||
}
|
||||
|
||||
public static (DateTime Start, DateTime End) MonthRangeUtc(int year, int month)
|
||||
{
|
||||
var start = new DateTime(year, month, 1);
|
||||
return (ToUtc(start), ToUtc(start.AddMonths(1)));
|
||||
}
|
||||
|
||||
public static (DateTime Start, DateTime End) YearRangeUtc(int year)
|
||||
{
|
||||
var start = new DateTime(year, 1, 1);
|
||||
return (ToUtc(start), ToUtc(start.AddYears(1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public class JwtService(IConfiguration config)
|
||||
{
|
||||
public (string Token, DateTime ExpiresAt) Issue(User user)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
|
||||
var expires = DateTime.UtcNow.AddDays(int.Parse(config["Jwt:ExpireDays"] ?? "30"));
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: config["Jwt:Issuer"],
|
||||
audience: config["Jwt:Audience"],
|
||||
claims:
|
||||
[
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, user.Username),
|
||||
new Claim("auth_version", user.AuthVersion.ToString()),
|
||||
],
|
||||
expires: expires,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), expires);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public class LedgerResolver(AppDbContext db)
|
||||
{
|
||||
public async Task<long?> ResolveAsync(
|
||||
long userId,
|
||||
long? requestedLedgerId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (requestedLedgerId.HasValue)
|
||||
{
|
||||
return await db.Ledgers
|
||||
.Where(l => l.Id == requestedLedgerId.Value && l.OwnerId == userId)
|
||||
.Select(l => (long?)l.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
return await db.Ledgers
|
||||
.Where(l => l.OwnerId == userId && l.IsDefault)
|
||||
.Select(l => (long?)l.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public interface ILlmClient
|
||||
{
|
||||
bool IsEnabled { get; }
|
||||
Task<IntentResult?> TryParseIntentAsync(string userText, CancellationToken ct = default);
|
||||
Task<string?> TryGenerateReplyAsync(string systemPrompt, string userText, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
|
||||
byte[] imageBytes,
|
||||
string mimeType,
|
||||
CancellationToken ct = default);
|
||||
Task<(bool Ok, string? Error)> TestConnectionAsync(CancellationToken ct = default);
|
||||
|
||||
Task<AgentRunResponse> RunAgentAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
|
||||
Func<string, CancellationToken, Task>? onToken = null,
|
||||
Func<string, CancellationToken, Task>? onToolRunning = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>流式生成回复。将 LLM 返回的 token 逐个写入 writer,完成时返回。</summary>
|
||||
Task StreamReplyAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
Func<string, CancellationToken, Task> onToken,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record AgentToolDefinition(
|
||||
string Name,
|
||||
string Description,
|
||||
object Parameters);
|
||||
|
||||
public record AgentToolCall(
|
||||
string CallId,
|
||||
string Name,
|
||||
string Arguments);
|
||||
|
||||
public record AgentRunResponse(
|
||||
string Text,
|
||||
int ToolCallCount);
|
||||
|
||||
public record ImageParseResult(
|
||||
string Type,
|
||||
decimal Amount,
|
||||
string CategoryName,
|
||||
string? PaymentMethod,
|
||||
string Note,
|
||||
DateTime? OccurredAt);
|
||||
|
||||
public class NullLlmClient : ILlmClient
|
||||
{
|
||||
public bool IsEnabled => false;
|
||||
|
||||
public Task<IntentResult?> TryParseIntentAsync(
|
||||
string userText,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult<IntentResult?>(null);
|
||||
|
||||
public Task<string?> TryGenerateReplyAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult<string?>(null);
|
||||
|
||||
public Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
|
||||
byte[] imageBytes,
|
||||
string mimeType,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ImageParseResult>?>(null);
|
||||
|
||||
public Task<(bool Ok, string? Error)> TestConnectionAsync(
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult<(bool, string?)>((false, "API Key 为空"));
|
||||
|
||||
public Task<AgentRunResponse> RunAgentAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
|
||||
Func<string, CancellationToken, Task>? onToken = null,
|
||||
Func<string, CancellationToken, Task>? onToolRunning = null,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult(new AgentRunResponse("", 0));
|
||||
|
||||
public async Task StreamReplyAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
Func<string, CancellationToken, Task> onToken,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public partial class OpenAiVisionClient
|
||||
{
|
||||
public async Task<AgentRunResponse> RunAgentAsync(
|
||||
string systemPrompt,
|
||||
string userText,
|
||||
IReadOnlyList<AgentToolDefinition> tools,
|
||||
Func<AgentToolCall, CancellationToken, Task<string>> executeTool,
|
||||
Func<string, CancellationToken, Task>? onToken = null,
|
||||
Func<string, CancellationToken, Task>? onToolRunning = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Load();
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
throw new InvalidOperationException("AI 功能尚未配置");
|
||||
if (_protocol != "responses")
|
||||
throw new InvalidOperationException("AI Agent 记账要求使用 Responses 协议");
|
||||
|
||||
var providerTools = tools.Select(tool => new
|
||||
{
|
||||
type = "function",
|
||||
name = tool.Name,
|
||||
description = tool.Description,
|
||||
parameters = tool.Parameters,
|
||||
}).ToArray();
|
||||
var input = new object[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userText },
|
||||
};
|
||||
|
||||
string? previousResponseId = null;
|
||||
IReadOnlyList<object>? toolOutputs = null;
|
||||
var text = new StringBuilder();
|
||||
var toolCallCount = 0;
|
||||
|
||||
for (var round = 0; round < 4; round++)
|
||||
{
|
||||
object body = previousResponseId is null
|
||||
? new
|
||||
{
|
||||
model = _model,
|
||||
input,
|
||||
tools = providerTools,
|
||||
tool_choice = "auto",
|
||||
max_output_tokens = _maxTokens,
|
||||
temperature = 0.3,
|
||||
thinking = new { type = "disabled" },
|
||||
store = true,
|
||||
stream = true,
|
||||
}
|
||||
: new
|
||||
{
|
||||
model = _model,
|
||||
previous_response_id = previousResponseId,
|
||||
input = toolOutputs,
|
||||
tools = providerTools,
|
||||
tool_choice = "auto",
|
||||
max_output_tokens = _maxTokens,
|
||||
temperature = 0.3,
|
||||
thinking = new { type = "disabled" },
|
||||
store = true,
|
||||
stream = true,
|
||||
};
|
||||
|
||||
var turn = await StreamAgentTurnAsync(
|
||||
body,
|
||||
async (token, tokenCt) =>
|
||||
{
|
||||
text.Append(token);
|
||||
if (onToken is not null) await onToken(token, tokenCt);
|
||||
},
|
||||
ct);
|
||||
previousResponseId = turn.ResponseId;
|
||||
|
||||
if (turn.Calls.Count == 0)
|
||||
return new AgentRunResponse(text.ToString().Trim(), toolCallCount);
|
||||
if (string.IsNullOrWhiteSpace(previousResponseId))
|
||||
throw new InvalidOperationException("AI 工具调用缺少响应 ID");
|
||||
|
||||
var outputs = new List<object>();
|
||||
foreach (var call in turn.Calls)
|
||||
{
|
||||
toolCallCount++;
|
||||
if (toolCallCount > 12)
|
||||
throw new InvalidOperationException("AI 工具调用次数过多,请重新描述需求");
|
||||
if (onToolRunning is not null)
|
||||
await onToolRunning(call.Name, ct);
|
||||
|
||||
string output;
|
||||
try
|
||||
{
|
||||
output = await executeTool(call, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Agent tool failed. tool={Tool} callId={CallId}",
|
||||
call.Name,
|
||||
call.CallId);
|
||||
output = JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
error = ex.Message,
|
||||
});
|
||||
}
|
||||
outputs.Add(new
|
||||
{
|
||||
type = "function_call_output",
|
||||
call_id = call.CallId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
toolOutputs = outputs;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("AI 工具调用轮次过多,请重新描述需求");
|
||||
}
|
||||
|
||||
private async Task<AgentProviderTurn> StreamAgentTurnAsync(
|
||||
object body,
|
||||
Func<string, CancellationToken, Task> onToken,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
_baseUrl + "/responses")
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(body),
|
||||
Encoding.UTF8,
|
||||
"application/json"),
|
||||
};
|
||||
request.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||||
|
||||
using var response = await _http.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var detail = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException(
|
||||
$"AI Agent 请求失败 ({(int)response.StatusCode}):{ReadAgentError(detail)}");
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
var calls = new Dictionary<string, AgentCallBuilder>();
|
||||
string? responseId = null;
|
||||
var emittedText = false;
|
||||
|
||||
while (!reader.EndOfStream && !ct.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
|
||||
if (!line.StartsWith("data:", StringComparison.Ordinal)) continue;
|
||||
|
||||
var payload = line[5..].TrimStart();
|
||||
if (payload.Length == 0 || payload == "[DONE]") continue;
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
var eventType = TryGetProperty(root, "type", out var typeNode)
|
||||
? typeNode.GetString()
|
||||
: null;
|
||||
|
||||
if (eventType is "response.failed" or "error")
|
||||
throw new InvalidOperationException(ReadAgentError(payload));
|
||||
|
||||
if (TryGetProperty(root, "response", out var responseNode) &&
|
||||
responseNode.ValueKind == JsonValueKind.Object &&
|
||||
TryGetProperty(responseNode, "id", out var responseIdNode))
|
||||
{
|
||||
responseId = responseIdNode.GetString() ?? responseId;
|
||||
}
|
||||
|
||||
if (eventType == "response.output_text.delta" &&
|
||||
TryGetProperty(root, "delta", out var deltaNode) &&
|
||||
deltaNode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var token = deltaNode.GetString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
emittedText = true;
|
||||
await onToken(token, ct);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType == "response.output_item.added" &&
|
||||
TryGetProperty(root, "item", out var itemNode))
|
||||
{
|
||||
AddFunctionCall(itemNode, calls);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType == "response.function_call_arguments.delta")
|
||||
{
|
||||
var key = ReadCallKey(root);
|
||||
if (key is not null && calls.TryGetValue(key, out var builder) &&
|
||||
TryGetProperty(root, "delta", out var argumentDelta))
|
||||
{
|
||||
builder.Arguments.Append(argumentDelta.GetString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType == "response.function_call_arguments.done")
|
||||
{
|
||||
var key = ReadCallKey(root);
|
||||
if (key is not null && calls.TryGetValue(key, out var builder) &&
|
||||
TryGetProperty(root, "arguments", out var argumentsNode))
|
||||
{
|
||||
builder.Arguments.Clear();
|
||||
builder.Arguments.Append(argumentsNode.GetString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType == "response.completed" &&
|
||||
TryGetProperty(root, "response", out var completedResponse))
|
||||
{
|
||||
AddFunctionCallsFromResponse(completedResponse, calls);
|
||||
if (!emittedText)
|
||||
{
|
||||
var completeText = ExtractResponseText(completedResponse);
|
||||
if (!string.IsNullOrWhiteSpace(completeText))
|
||||
{
|
||||
emittedText = true;
|
||||
await onToken(completeText, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentProviderTurn(
|
||||
responseId,
|
||||
calls.Values
|
||||
.Where(call => !string.IsNullOrWhiteSpace(call.CallId) &&
|
||||
!string.IsNullOrWhiteSpace(call.Name))
|
||||
.Select(call => new AgentToolCall(
|
||||
call.CallId,
|
||||
call.Name,
|
||||
call.Arguments.ToString()))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private static void AddFunctionCallsFromResponse(
|
||||
JsonElement response,
|
||||
Dictionary<string, AgentCallBuilder> calls)
|
||||
{
|
||||
if (!TryGetProperty(response, "output", out var output) ||
|
||||
output.ValueKind != JsonValueKind.Array) return;
|
||||
foreach (var item in output.EnumerateArray()) AddFunctionCall(item, calls);
|
||||
}
|
||||
|
||||
private static void AddFunctionCall(
|
||||
JsonElement item,
|
||||
Dictionary<string, AgentCallBuilder> calls)
|
||||
{
|
||||
if (!TryGetProperty(item, "type", out var type) ||
|
||||
type.GetString() != "function_call") return;
|
||||
|
||||
var itemId = TryGetProperty(item, "id", out var idNode)
|
||||
? idNode.GetString()
|
||||
: null;
|
||||
var callId = TryGetProperty(item, "call_id", out var callIdNode)
|
||||
? callIdNode.GetString()
|
||||
: null;
|
||||
var name = TryGetProperty(item, "name", out var nameNode)
|
||||
? nameNode.GetString()
|
||||
: null;
|
||||
var key = itemId ?? callId;
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(callId)) return;
|
||||
|
||||
if (!calls.TryGetValue(key, out var builder))
|
||||
{
|
||||
builder = new AgentCallBuilder(callId!, name ?? "");
|
||||
calls[key] = builder;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(name)) builder.Name = name!;
|
||||
if (TryGetProperty(item, "arguments", out var arguments) &&
|
||||
arguments.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(arguments.GetString()))
|
||||
{
|
||||
builder.Arguments.Clear();
|
||||
builder.Arguments.Append(arguments.GetString());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadCallKey(JsonElement root)
|
||||
{
|
||||
if (TryGetProperty(root, "item_id", out var itemId))
|
||||
return itemId.GetString();
|
||||
if (TryGetProperty(root, "call_id", out var callId))
|
||||
return callId.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadAgentError(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(raw);
|
||||
var root = document.RootElement;
|
||||
if (TryGetProperty(root, "error", out var error))
|
||||
{
|
||||
if (error.ValueKind == JsonValueKind.String)
|
||||
return error.GetString() ?? "AI 服务返回错误";
|
||||
if (TryGetProperty(error, "message", out var message))
|
||||
return message.GetString() ?? error.ToString();
|
||||
return error.ToString();
|
||||
}
|
||||
if (TryGetProperty(root, "message", out var directMessage))
|
||||
return directMessage.GetString() ?? "AI 服务返回错误";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
return raw.Length > 300 ? raw[..300] : raw;
|
||||
}
|
||||
|
||||
private sealed record AgentProviderTurn(
|
||||
string? ResponseId,
|
||||
IReadOnlyList<AgentToolCall> Calls);
|
||||
|
||||
private sealed class AgentCallBuilder(string callId, string name)
|
||||
{
|
||||
public string CallId { get; } = callId;
|
||||
public string Name { get; set; } = name;
|
||||
public StringBuilder Arguments { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using MiaoJiZhang.Domain.Enums;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public partial class OpenAiVisionClient : ILlmClient
|
||||
{
|
||||
private readonly IServiceScopeFactory _sf;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
private static readonly object _lk = new();
|
||||
|
||||
public OpenAiVisionClient(
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
{
|
||||
_sf = sf;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_logger = logger;
|
||||
}
|
||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||
|
||||
public async Task<IntentResult?> TryParseIntentAsync(
|
||||
string userText,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
const string prompt =
|
||||
"你是记账意图识别器。只返回 JSON:" +
|
||||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
|
||||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||||
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
|
||||
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
|
||||
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
|
||||
"查询句不得返回 bill,未明确表示收入的金额默认 expense。";
|
||||
var response = await L(prompt, userText, ct);
|
||||
return response is null ? null : PI(response);
|
||||
}
|
||||
public async Task<string?> TryGenerateReplyAsync(string s, string u, CancellationToken ct = default)
|
||||
{ if (!IsEnabled) return null; return await L(s, u, ct); }
|
||||
|
||||
public async Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
|
||||
byte[] img,
|
||||
string mime,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
|
||||
var b64 = Convert.ToBase64String(img);
|
||||
var shanghaiNow = ChinaClock.Now;
|
||||
var systemPrompt = $$"""
|
||||
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
|
||||
{"bills":[{"type":"expense","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||||
type 只能是 expense 或 income。
|
||||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||||
note 只写简短商户、商品或交易对象,不要抄整行原文。
|
||||
当前上海时间是 {{shanghaiNow:yyyy-MM-dd HH:mm:ss}}。截图明确显示完整日期和时间时,occurredAt 返回带 +08:00 的 ISO 8601 时间;
|
||||
“今天、昨天、前天”等相对日期按当前上海时间换算。缺少日期或缺少具体时分时 occurredAt 必须返回 null,不得猜测。
|
||||
不要把总计、余额、优惠、原价、待支付、统计数字或重复展示的同一笔交易当成独立账单。
|
||||
无法确认真实交易时返回 {"bills":[]},最多返回 20 笔。
|
||||
""";
|
||||
var messages = new List<object>();
|
||||
|
||||
if (_protocol == "messages")
|
||||
{
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "image",
|
||||
source = new { type = "base64", media_type = mime, data = b64 },
|
||||
},
|
||||
new { type = "text", text = systemPrompt },
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (_protocol == "responses")
|
||||
{
|
||||
messages.Add(new { role = "system", content = systemPrompt });
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new { type = "input_image", image_url = "data:" + mime + ";base64," + b64 },
|
||||
new { type = "input_text", text = "提取截图中的全部独立交易" },
|
||||
},
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
messages.Add(new { role = "system", content = systemPrompt });
|
||||
messages.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "image_url",
|
||||
image_url = new { url = "data:" + mime + ";base64," + b64 },
|
||||
},
|
||||
new { type = "text", text = "提取截图中的全部独立交易" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
var diagnosticId = Guid.NewGuid().ToString("N")[..12];
|
||||
var (json, error) = await CA(BuildBody(messages, 1536, 0.1), ct);
|
||||
if (json is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Image model request failed. model={Model} protocol={Protocol} requestId={RequestId} error={Error}",
|
||||
_model,
|
||||
_protocol,
|
||||
diagnosticId,
|
||||
error);
|
||||
throw new InvalidOperationException(error ?? "图片模型未返回内容");
|
||||
}
|
||||
|
||||
var providerRequestId = ReadProviderRequestId(json) ?? diagnosticId;
|
||||
var output = EX(json);
|
||||
try
|
||||
{
|
||||
var results = ParseImageResults(output);
|
||||
_logger.LogInformation(
|
||||
"Image model parse completed. model={Model} protocol={Protocol} requestId={RequestId} items={Count}",
|
||||
_model,
|
||||
_protocol,
|
||||
providerRequestId,
|
||||
results.Count);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Image model parse failed. model={Model} protocol={Protocol} requestId={RequestId} output={Output}",
|
||||
_model,
|
||||
_protocol,
|
||||
providerRequestId,
|
||||
SanitizeOutputSnippet(output));
|
||||
throw new InvalidOperationException("图片模型返回格式无法解析,请重试", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ImageParseResult> ParseImageResults(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException("模型没有返回可解析内容");
|
||||
|
||||
InvalidOperationException? shapeError = null;
|
||||
foreach (var candidate in EnumerateJsonCandidates(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(
|
||||
candidate,
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
});
|
||||
var results = new List<ImageParseResult>();
|
||||
if (TryReadImageRoot(document.RootElement, results))
|
||||
return results;
|
||||
shapeError = new InvalidOperationException("JSON 中没有账单字段");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Keep scanning: models often add prose or multiple fenced blocks.
|
||||
}
|
||||
}
|
||||
|
||||
if (LooksLikeNoBillReply(raw)) return [];
|
||||
throw shapeError ?? new InvalidOperationException("未找到完整 JSON");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateJsonCandidates(string raw)
|
||||
{
|
||||
for (var start = 0; start < raw.Length; start++)
|
||||
{
|
||||
var opening = raw[start];
|
||||
if (opening is not ('{' or '[')) continue;
|
||||
|
||||
var stack = new Stack<char>();
|
||||
var inString = false;
|
||||
var escaped = false;
|
||||
for (var index = start; index < raw.Length; index++)
|
||||
{
|
||||
var current = raw[index];
|
||||
if (inString)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
}
|
||||
else if (current == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (current == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current == '"')
|
||||
{
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (current is '{' or '[')
|
||||
{
|
||||
stack.Push(current);
|
||||
continue;
|
||||
}
|
||||
if (current is not ('}' or ']')) continue;
|
||||
if (
|
||||
stack.Count == 0 ||
|
||||
(current == '}' && stack.Peek() != '{') ||
|
||||
(current == ']' && stack.Peek() != '[')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
stack.Pop();
|
||||
if (stack.Count == 0)
|
||||
{
|
||||
yield return raw[start..(index + 1)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadImageRoot(
|
||||
JsonElement root,
|
||||
List<ImageParseResult> results)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray()) AddBill(item, results);
|
||||
return true;
|
||||
}
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
foreach (var name in new[] { "bills", "items", "transactions", "data" })
|
||||
{
|
||||
if (!TryGetProperty(root, name, out var items)) continue;
|
||||
if (items.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in items.EnumerateArray()) AddBill(item, results);
|
||||
return true;
|
||||
}
|
||||
if (items.ValueKind == JsonValueKind.Object)
|
||||
return TryReadImageRoot(items, results);
|
||||
if (items.ValueKind == JsonValueKind.Null) return true;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "result", out var nested))
|
||||
return TryReadImageRoot(nested, results);
|
||||
if (
|
||||
TryGetProperty(root, "matched", out var matched) &&
|
||||
matched.ValueKind == JsonValueKind.False
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (TryGetProperty(root, "amount", out _))
|
||||
{
|
||||
AddBill(root, results);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddBill(
|
||||
JsonElement item,
|
||||
List<ImageParseResult> results)
|
||||
{
|
||||
if (results.Count >= 20 || item.ValueKind != JsonValueKind.Object) return;
|
||||
var amount = ReadAmount(item);
|
||||
if (amount <= 0) return;
|
||||
|
||||
var rawType = ReadText(item, "type", "transactionType", "direction");
|
||||
var type = rawType?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"income" or "收入" or "入账" => "income",
|
||||
"expense" or "支出" or "出账" => "expense",
|
||||
_ => null,
|
||||
};
|
||||
if (type is null) return;
|
||||
var category = ReadText(
|
||||
item,
|
||||
"categoryName",
|
||||
"category_name",
|
||||
"category");
|
||||
var payment = ReadText(
|
||||
item,
|
||||
"paymentMethod",
|
||||
"payment_method",
|
||||
"payment",
|
||||
"payMethod");
|
||||
var note = ReadText(
|
||||
item,
|
||||
"note",
|
||||
"merchant",
|
||||
"description",
|
||||
"title",
|
||||
"counterparty");
|
||||
var occurredAt = ReadOccurredAt(item);
|
||||
|
||||
results.Add(new ImageParseResult(
|
||||
type,
|
||||
amount,
|
||||
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
|
||||
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
|
||||
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
|
||||
occurredAt));
|
||||
}
|
||||
|
||||
private static DateTime? ReadOccurredAt(JsonElement item)
|
||||
{
|
||||
var text = ReadText(
|
||||
item,
|
||||
"occurredAt",
|
||||
"occurred_at",
|
||||
"transactionTime",
|
||||
"transaction_time",
|
||||
"dateTime");
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
// Reject date-only values so the client can deliberately fall back to now.
|
||||
if (!Regex.IsMatch(text, @"\b\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}") ||
|
||||
!Regex.IsMatch(text, @"\b\d{1,2}:\d{2}\b"))
|
||||
return null;
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AllowWhiteSpaces,
|
||||
out var offsetValue))
|
||||
{
|
||||
var utc = offsetValue.UtcDateTime;
|
||||
return utc <= DateTime.UtcNow.AddMinutes(10) ? utc : null;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.GetCultureInfo("zh-CN"),
|
||||
DateTimeStyles.AllowWhiteSpaces,
|
||||
out var localValue))
|
||||
return null;
|
||||
var localUtc = ChinaClock.ToUtc(localValue);
|
||||
return localUtc <= DateTime.UtcNow.AddMinutes(10) ? localUtc : null;
|
||||
}
|
||||
|
||||
private static decimal ReadAmount(JsonElement item)
|
||||
{
|
||||
JsonElement amount = default;
|
||||
var found =
|
||||
TryGetProperty(item, "amount", out amount) ||
|
||||
TryGetProperty(item, "money", out amount) ||
|
||||
TryGetProperty(item, "price", out amount);
|
||||
if (!found) return 0;
|
||||
if (
|
||||
amount.ValueKind == JsonValueKind.Number &&
|
||||
amount.TryGetDecimal(out var number)
|
||||
) {
|
||||
return number;
|
||||
}
|
||||
if (amount.ValueKind != JsonValueKind.String) return 0;
|
||||
|
||||
var text = amount.GetString()?.Replace(",", "");
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
var match = Regex.Match(text, @"-?\d+(?:\.\d+)?");
|
||||
return match.Success &&
|
||||
decimal.TryParse(
|
||||
match.Value,
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static string? ReadText(JsonElement item, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (!TryGetProperty(item, name, out var value)) continue;
|
||||
if (value.ValueKind == JsonValueKind.String) return value.GetString();
|
||||
if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
|
||||
return value.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(
|
||||
JsonElement element,
|
||||
string name,
|
||||
out JsonElement value)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeNoBillReply(string raw)
|
||||
{
|
||||
return raw.Contains("没有账单", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("未发现交易", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("无法确认", StringComparison.OrdinalIgnoreCase) ||
|
||||
raw.Contains("no transaction", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string? ReadProviderRequestId(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
foreach (var name in new[] { "id", "request_id", "requestId" })
|
||||
{
|
||||
if (
|
||||
TryGetProperty(root, name, out var value) &&
|
||||
value.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// The normal response parser will report the malformed payload.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SanitizeOutputSnippet(string raw)
|
||||
{
|
||||
var sanitized = Regex.Replace(
|
||||
raw,
|
||||
@"(?i)""(note|merchant|description|counterparty|paymentMethod|payment_method)""\s*:\s*""(?:\\.|[^""])*""",
|
||||
@"""$1"":""***""");
|
||||
sanitized = Regex.Replace(sanitized, @"\b\d{4,}\b", "***");
|
||||
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
|
||||
return sanitized.Length <= 500 ? sanitized : sanitized[..500] + "…";
|
||||
}
|
||||
|
||||
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
if (_protocol != "responses")
|
||||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new AgentToolDefinition(
|
||||
"diagnostic_echo",
|
||||
"连接测试时必须调用的无副作用工具",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
"""{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""));
|
||||
var response = await RunAgentAsync(
|
||||
"你正在执行连接测试。必须调用 diagnostic_echo,参数 value 填 ok。",
|
||||
"执行工具调用测试",
|
||||
[tool],
|
||||
(_, _) => Task.FromResult("{\"ok\":true}"),
|
||||
ct: ct);
|
||||
return response.ToolCallCount > 0
|
||||
? (true, null)
|
||||
: (false, "模型返回了文本,但没有触发 Function Calling");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StreamReplyAsync(string s, string u, Func<string, CancellationToken, Task> onToken, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
Load();
|
||||
|
||||
|
||||
var msgs = new List<object>();
|
||||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
||||
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
|
||||
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||||
lock (_lk)
|
||||
{
|
||||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||||
try
|
||||
{
|
||||
using var scope = _sf.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
||||
"").TrimEnd('/');
|
||||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
||||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
||||
_maxTokens = int.TryParse(
|
||||
config.GetValueOrDefault("llm.max_tokens"),
|
||||
out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_last = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to load LLM configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
|
||||
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
|
||||
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
|
||||
|
||||
async Task<(string?, string?)> CA(object body, CancellationToken ct)
|
||||
{
|
||||
Load();
|
||||
try
|
||||
{
|
||||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||||
var json = JsonSerializer.Serialize(body);
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||||
var r = await _http.SendAsync(req, ct); var t = await r.Content.ReadAsStringAsync(ct);
|
||||
if (r.IsSuccessStatusCode) return (t, null);
|
||||
var detail = "";
|
||||
try { var e = JsonDocument.Parse(t).RootElement; if (e.TryGetProperty("error", out var er)) { string? em = null; if (er.TryGetProperty("message", out var m)) em = m.GetString(); detail = ": " + (em ?? er.ToString()); } } catch { }
|
||||
var msg = ((int)r.StatusCode) switch { 401 => "401 认证失败", 403 => "403 禁止访问", 404 => "404 接口不存在", 429 => "429 频率过高", 500 => "500 服务异常", _ => ((int)r.StatusCode).ToString() };
|
||||
return (null, msg + detail);
|
||||
}
|
||||
catch (TaskCanceledException) { return (null, "请求超时"); }
|
||||
catch (HttpRequestException ex) { return (null, "网络错误: " + (ex.InnerException?.Message ?? ex.Message)); }
|
||||
catch (Exception ex) { return (null, ex.Message); }
|
||||
}
|
||||
|
||||
async Task SA(object body, Func<string, CancellationToken, Task> onToken, CancellationToken ct)
|
||||
{
|
||||
Load();
|
||||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||||
var serialized = JsonSerializer.Serialize(body);
|
||||
var json = serialized[..^1] + ",\"stream\":true}";
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||||
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"LLM 流式请求失败 ({(int)resp.StatusCode})");
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
var emitted = false;
|
||||
while (!reader.EndOfStream && !ct.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
|
||||
var failure = StreamFailure(line);
|
||||
if (failure is not null) throw new InvalidOperationException(failure);
|
||||
var tok = ET(line);
|
||||
if (!string.IsNullOrWhiteSpace(tok)) { emitted = true; await onToken(tok, ct); }
|
||||
}
|
||||
if (!emitted) throw new InvalidOperationException("AI 未返回内容");
|
||||
}
|
||||
|
||||
static string? StreamFailure(string line)
|
||||
{
|
||||
var i = line.IndexOf('{');
|
||||
if (i < 0) return null;
|
||||
try
|
||||
{
|
||||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||||
if (!d.TryGetProperty("type", out var type)) return null;
|
||||
var value = type.GetString();
|
||||
if (value == "response.incomplete")
|
||||
{
|
||||
var reason = d.TryGetProperty("response", out var response)
|
||||
&& response.TryGetProperty("incomplete_details", out var details)
|
||||
&& details.TryGetProperty("reason", out var reasonElement)
|
||||
? reasonElement.GetString() : null;
|
||||
return reason == "length" ? "AI 输出长度不足,请稍后重试" : "AI 回复未完成";
|
||||
}
|
||||
if (value is "response.failed" or "error") return "AI 服务返回失败";
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
static string? ET(string line)
|
||||
{
|
||||
if (!line.StartsWith("data: ") && !line.StartsWith("data:")) return null;
|
||||
var i = line.IndexOf('{'); if (i < 0) return null;
|
||||
try
|
||||
{
|
||||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||||
if (d.TryGetProperty("choices", out var ch) && ch.ValueKind == JsonValueKind.Array && ch.GetArrayLength() > 0 && ch[0].TryGetProperty("delta", out var dt) && dt.TryGetProperty("content", out var ct)) return ct.GetString();
|
||||
if (d.TryGetProperty("type", out var t))
|
||||
{ var ty = t.GetString(); if ((ty == "response.output_text.delta" || ty == "content_block_delta") && d.TryGetProperty("delta", out var dl)) { if (dl.ValueKind == JsonValueKind.String) return dl.GetString(); if (dl.TryGetProperty("text", out var tx)) return tx.GetString(); } }
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
string EX(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return ExtractResponseText(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractResponseText(JsonElement root)
|
||||
{
|
||||
if (
|
||||
TryGetProperty(root, "output_text", out var directOutput) &&
|
||||
directOutput.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return directOutput.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "choices", out var choices) &&
|
||||
choices.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var choice in choices.EnumerateArray())
|
||||
{
|
||||
if (!TryGetProperty(choice, "message", out var message)) continue;
|
||||
if (!TryGetProperty(message, "content", out var content)) continue;
|
||||
var value = ExtractTextParts(content);
|
||||
if (value.Length > 0) return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "content", out var anthropicContent))
|
||||
{
|
||||
var value = ExtractTextParts(anthropicContent);
|
||||
if (value.Length > 0) return value;
|
||||
}
|
||||
|
||||
if (TryGetProperty(root, "output", out var output) &&
|
||||
output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var messageParts = new List<string>();
|
||||
var fallbackParts = new List<string>();
|
||||
foreach (var item in output.EnumerateArray())
|
||||
{
|
||||
var type = TryGetProperty(item, "type", out var typeElement)
|
||||
? typeElement.GetString()
|
||||
: null;
|
||||
if (type == "message" &&
|
||||
TryGetProperty(item, "content", out var messageContent))
|
||||
{
|
||||
var value = ExtractTextParts(messageContent);
|
||||
if (value.Length > 0) messageParts.Add(value);
|
||||
}
|
||||
if (TryGetProperty(item, "summary", out var summary))
|
||||
{
|
||||
var value = ExtractTextParts(summary);
|
||||
if (value.Length > 0) fallbackParts.Add(value);
|
||||
}
|
||||
}
|
||||
if (messageParts.Count > 0) return string.Join("", messageParts);
|
||||
if (fallbackParts.Count > 0) return string.Join("", fallbackParts);
|
||||
}
|
||||
|
||||
if (
|
||||
TryGetProperty(root, "response", out var response) &&
|
||||
response.ValueKind == JsonValueKind.Object
|
||||
) {
|
||||
return ExtractResponseText(response);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string ExtractTextParts(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
return element.GetString() ?? "";
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
var value = ExtractTextParts(item);
|
||||
if (value.Length > 0) parts.Add(value);
|
||||
}
|
||||
return string.Join("", parts);
|
||||
}
|
||||
if (element.ValueKind != JsonValueKind.Object) return "";
|
||||
|
||||
if (TryGetProperty(element, "text", out var text))
|
||||
{
|
||||
if (text.ValueKind == JsonValueKind.String)
|
||||
return text.GetString() ?? "";
|
||||
if (
|
||||
text.ValueKind == JsonValueKind.Object &&
|
||||
TryGetProperty(text, "value", out var textValue) &&
|
||||
textValue.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return textValue.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
if (
|
||||
TryGetProperty(element, "output_text", out var outputText) &&
|
||||
outputText.ValueKind == JsonValueKind.String
|
||||
) {
|
||||
return outputText.GetString() ?? "";
|
||||
}
|
||||
if (TryGetProperty(element, "content", out var content))
|
||||
return ExtractTextParts(content);
|
||||
return "";
|
||||
}
|
||||
|
||||
static IntentResult? PI(string response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = response.Trim();
|
||||
var fence = new string((char)96, 3);
|
||||
if (json.StartsWith(fence))
|
||||
{
|
||||
json = json.Replace(fence, "").Trim();
|
||||
if (json.StartsWith("json", StringComparison.OrdinalIgnoreCase))
|
||||
json = json[4..].Trim();
|
||||
}
|
||||
|
||||
var document = JsonDocument.Parse(json).RootElement;
|
||||
var kind = document.TryGetProperty("kind", out var kindNode)
|
||||
? kindNode.GetString() ?? "chat"
|
||||
: "chat";
|
||||
if (kind != "bill") return new IntentResult(kind, null);
|
||||
|
||||
var typeText = document.TryGetProperty("type", out var typeNode)
|
||||
? typeNode.GetString()
|
||||
: null;
|
||||
typeText = typeText?.Trim().ToLowerInvariant();
|
||||
if (typeText is null ||
|
||||
typeText is not ("income" or "expense"))
|
||||
return new IntentResult("chat", null);
|
||||
var type = typeText == "income"
|
||||
? TransactionType.Income
|
||||
: TransactionType.Expense;
|
||||
var category = document.TryGetProperty(
|
||||
"categoryName",
|
||||
out var categoryNode)
|
||||
? categoryNode.GetString() ?? "其他"
|
||||
: "其他";
|
||||
var note = document.TryGetProperty("note", out var noteNode)
|
||||
? noteNode.GetString() ?? category
|
||||
: category;
|
||||
var amount = document.TryGetProperty("amount", out var amountNode) &&
|
||||
amountNode.TryGetDecimal(out var value)
|
||||
? value
|
||||
: 0;
|
||||
return new IntentResult(
|
||||
"bill",
|
||||
new ParsedBill(
|
||||
type,
|
||||
0,
|
||||
category,
|
||||
note,
|
||||
amount,
|
||||
null));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public class RecycleBinCleanupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<RecycleBinCleanupService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Cleanup(stoppingToken);
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await Cleanup(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task Cleanup(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var ids = await db.Transactions.IgnoreQueryFilters()
|
||||
.Where(t => t.IsDeleted && t.DeletedAt < cutoff)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
if (ids.Count == 0) return;
|
||||
|
||||
await db.ChatMessages
|
||||
.Where(message =>
|
||||
message.TransactionId.HasValue &&
|
||||
ids.Contains(message.TransactionId.Value))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(message => message.TransactionId, (long?)null)
|
||||
.SetProperty(message => message.Content, """{"deleted":true}"""), ct);
|
||||
await db.Transactions.IgnoreQueryFilters()
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
logger.LogInformation(
|
||||
"Permanently removed {Count} transactions after recycle-bin retention",
|
||||
ids.Count);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Recycle-bin cleanup failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user