Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -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");
}
}
}