Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
@@ -0,0 +1,71 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
namespace MiaoJiZhang.Api.Services;
public sealed class AdminAuditMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, AppDbContext db)
{
var isAdmin = context.Request.Path.StartsWithSegments("/api/admin");
var isAuth = context.Request.Path.StartsWithSegments("/api/admin/auth");
var shouldAudit = isAdmin && (isAuth ||
!AdminSessionService.IsSafeMethod(context.Request.Method));
if (!shouldAudit)
{
await next(context);
return;
}
Exception? failure = null;
try
{
await next(context);
}
catch (Exception exception)
{
failure = exception;
throw;
}
finally
{
try
{
var principal = AdminRequestContext.Principal(context);
var attemptedUsername = context.Items.TryGetValue(
AdminRequestContext.AuditUsernameKey,
out var attemptedValue)
? attemptedValue?.ToString()
: null;
var status = failure is null
? context.Response.StatusCode
: StatusCodes.Status500InternalServerError;
db.AdminAuditLogs.Add(new AdminAuditLog
{
AdminUserId = principal?.UserId,
Username = principal?.Username ?? attemptedUsername,
Action = ActionName(context),
Resource = context.Request.Path.Value ?? "/api/admin",
HttpMethod = context.Request.Method,
Path = (context.Request.Path + context.Request.QueryString).ToString(),
StatusCode = status,
Success = failure is null && status < 400,
Detail = failure?.GetType().Name,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync(CancellationToken.None);
}
catch
{
// Audit persistence must not replace the original API result.
}
}
}
private static string ActionName(HttpContext context)
{
var path = context.Request.Path.Value?.Trim('/').Replace('/', '.') ?? "api.admin";
return $"{context.Request.Method.ToLowerInvariant()}.{path}";
}
}
@@ -1,24 +1,49 @@
using MiaoJiZhang.Domain.Entities;
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
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class AdminAuthAttribute(params string[] roles) : Attribute, IAsyncAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
public async Task OnAuthorizationAsync(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)
var request = context.HttpContext.Request;
var service = context.HttpContext.RequestServices.GetRequiredService<AdminSessionService>();
var authenticated = await service.AuthenticateAsync(
context.HttpContext,
validateCsrf: !AdminSessionService.IsSafeMethod(request.Method),
context.HttpContext.RequestAborted);
if (authenticated is null)
{
context.Result = new UnauthorizedObjectResult(new { error = "admin_key_required", message = "请在 Header 中提供 X-Admin-Key" });
context.Result = new UnauthorizedObjectResult(new
{
error = "admin_session_required",
message = "管理会话已失效,请重新登录",
});
return;
}
var principal = authenticated.Value.Principal;
if (principal.MustChangePassword &&
!request.Path.StartsWithSegments("/api/admin/auth"))
{
context.Result = new ObjectResult(new
{
error = "password_change_required",
message = "首次登录必须修改密码",
}) { StatusCode = StatusCodes.Status403Forbidden };
return;
}
if (principal.Role == AdminRoles.Viewer &&
!AdminSessionService.IsSafeMethod(request.Method) &&
!request.Path.StartsWithSegments("/api/admin/auth"))
{
context.Result = new ForbidResult();
return;
}
if (roles.Length > 0 && !roles.Contains(principal.Role))
context.Result = new ForbidResult();
}
}
}
@@ -0,0 +1,38 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed class AdminBootstrapService(
AppDbContext db,
IConfiguration configuration,
ILogger<AdminBootstrapService> logger)
{
public async Task EnsureAsync(CancellationToken ct = default)
{
if (await db.AdminUsers.AnyAsync(ct)) return;
var username = configuration["Admin:BootstrapUsername"]?.Trim();
var password = configuration["Admin:BootstrapPassword"];
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
string.IsNullOrWhiteSpace(password) || password.Length < 12)
{
throw new InvalidOperationException(
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
}
var now = DateTime.UtcNow;
db.AdminUsers.Add(new AdminUser
{
Username = username,
PasswordHash = AdminSessionService.HashPassword(password),
Role = AdminRoles.SuperAdmin,
IsActive = true,
MustChangePassword = true,
CreatedAt = now,
UpdatedAt = now,
});
await db.SaveChangesAsync(ct);
logger.LogWarning("Bootstrapped the first super administrator account: {Username}", username);
}
}
@@ -0,0 +1,217 @@
using System.Security.Cryptography;
using System.Text;
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed record AdminPrincipal(
long UserId,
long SessionId,
string Username,
string Role,
bool MustChangePassword);
public sealed record AdminLoginResult(AdminPrincipal Principal, string CsrfToken);
public static class AdminRequestContext
{
public const string PrincipalKey = "miaoji.admin.principal";
public const string AuditUsernameKey = "miaoji.admin.audit.username";
public static AdminPrincipal? Principal(HttpContext context) =>
context.Items.TryGetValue(PrincipalKey, out var value)
? value as AdminPrincipal
: null;
}
public sealed class AdminSessionService(
AppDbContext db,
IConfiguration configuration,
IWebHostEnvironment environment)
{
public const string CookieName = "miaoji_admin_session";
public const string CsrfHeader = "X-CSRF-Token";
private static readonly TimeSpan IdleLifetime = TimeSpan.FromHours(8);
private static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
private static readonly TimeSpan LockoutLifetime = TimeSpan.FromMinutes(15);
public async Task<AdminLoginResult?> LoginAsync(
HttpContext context,
string username,
string password,
CancellationToken ct)
{
var normalizedUsername = username.Trim();
context.Items[AdminRequestContext.AuditUsernameKey] = normalizedUsername;
var user = await db.AdminUsers.FirstOrDefaultAsync(
item => item.Username == normalizedUsername,
ct);
var now = DateTime.UtcNow;
if (user is null || !user.IsActive ||
user.LockedUntil.HasValue && user.LockedUntil.Value > now)
{
BCrypt.Net.BCrypt.Verify(password, DummyPasswordHash());
return null;
}
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
user.FailedLoginCount++;
if (user.FailedLoginCount >= 5)
{
user.FailedLoginCount = 0;
user.LockedUntil = now.Add(LockoutLifetime);
}
user.UpdatedAt = now;
await db.SaveChangesAsync(ct);
return null;
}
user.FailedLoginCount = 0;
user.LockedUntil = null;
user.LastLoginAt = now;
user.UpdatedAt = now;
var rawToken = NewToken();
var csrfToken = NewToken();
var session = new AdminSession
{
AdminUser = user,
TokenHash = Hash(rawToken),
CsrfTokenHash = Hash(csrfToken),
AuthVersion = user.AuthVersion,
ExpiresAt = now.Add(IdleLifetime),
AbsoluteExpiresAt = now.Add(AbsoluteLifetime),
LastSeenAt = now,
IpAddress = ClientIp(context),
UserAgent = Trim(context.Request.Headers.UserAgent.ToString(), 300),
CreatedAt = now,
};
db.AdminSessions.Add(session);
await db.SaveChangesAsync(ct);
WriteCookie(context, rawToken, session.AbsoluteExpiresAt);
var principal = ToPrincipal(user, session);
context.Items[AdminRequestContext.PrincipalKey] = principal;
return new AdminLoginResult(principal, csrfToken);
}
public async Task<(AdminPrincipal Principal, string CsrfToken)?> AuthenticateAsync(
HttpContext context,
bool validateCsrf,
CancellationToken ct)
{
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) ||
string.IsNullOrWhiteSpace(token))
return null;
var tokenHash = Hash(token);
var now = DateTime.UtcNow;
var session = await db.AdminSessions
.Include(item => item.AdminUser)
.FirstOrDefaultAsync(item => item.TokenHash == tokenHash, ct);
if (session is null || session.RevokedAt.HasValue ||
session.ExpiresAt <= now || session.AbsoluteExpiresAt <= now ||
!session.AdminUser.IsActive ||
session.AuthVersion != session.AdminUser.AuthVersion)
{
DeleteCookie(context);
return null;
}
var csrfToken = context.Request.Headers[CsrfHeader].FirstOrDefault();
if (validateCsrf && (string.IsNullOrWhiteSpace(csrfToken) ||
!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(Hash(csrfToken)),
Encoding.ASCII.GetBytes(session.CsrfTokenHash))))
{
return null;
}
if (now - session.LastSeenAt >= TimeSpan.FromMinutes(5))
{
session.LastSeenAt = now;
session.ExpiresAt = Min(now.Add(IdleLifetime), session.AbsoluteExpiresAt);
await db.SaveChangesAsync(ct);
}
var principal = ToPrincipal(session.AdminUser, session);
context.Items[AdminRequestContext.PrincipalKey] = principal;
return (principal, csrfToken ?? string.Empty);
}
public async Task<string> RotateCsrfAsync(long sessionId, CancellationToken ct)
{
var session = await db.AdminSessions.FindAsync([sessionId], ct) ??
throw new InvalidOperationException("管理会话不存在");
var token = NewToken();
session.CsrfTokenHash = Hash(token);
await db.SaveChangesAsync(ct);
return token;
}
public async Task LogoutAsync(HttpContext context, long sessionId, CancellationToken ct)
{
var session = await db.AdminSessions.FindAsync([sessionId], ct);
if (session is not null && !session.RevokedAt.HasValue)
{
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
}
DeleteCookie(context);
}
public async Task RevokeOtherSessionsAsync(
long userId,
long currentSessionId,
int authVersion,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await db.AdminSessions
.Where(item => item.AdminUserId == userId && item.Id != currentSessionId &&
!item.RevokedAt.HasValue)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.RevokedAt, now), ct);
var current = await db.AdminSessions.FindAsync([currentSessionId], ct);
if (current is not null) current.AuthVersion = authVersion;
}
public static string HashPassword(string password) =>
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
public static bool IsSafeMethod(string method) =>
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
private void WriteCookie(HttpContext context, string token, DateTime expiresAt) =>
context.Response.Cookies.Append(CookieName, token, CookieOptions(context, expiresAt));
private void DeleteCookie(HttpContext context) =>
context.Response.Cookies.Delete(CookieName, CookieOptions(context, DateTime.UtcNow.AddDays(-1)));
private CookieOptions CookieOptions(HttpContext context, DateTime expiresAt) => new()
{
HttpOnly = true,
Secure = configuration.GetValue<bool?>("Admin:CookieSecure") ??
(!environment.IsDevelopment() || context.Request.IsHttps),
SameSite = SameSiteMode.Strict,
Path = "/api/admin",
IsEssential = true,
Expires = expiresAt,
};
private static AdminPrincipal ToPrincipal(AdminUser user, AdminSession session) =>
new(user.Id, session.Id, user.Username, user.Role, user.MustChangePassword);
private static DateTime Min(DateTime left, DateTime right) => left <= right ? left : right;
private static string NewToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string Hash(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static string? ClientIp(HttpContext context) =>
Trim(context.Connection.RemoteIpAddress?.ToString(), 64);
private static string? Trim(string? value, int length) =>
string.IsNullOrEmpty(value) ? null : value.Length <= length ? value : value[..length];
private static string DummyPasswordHash() =>
"$2a$12$1i3L4fD4PrM9xMVzKDnwoO.nGsRoW6u9Q9tT6A4vPi04QoV9S3Mca";
}
@@ -34,8 +34,8 @@ public class AgentService(
"properties": {
"type": {
"type": "string",
"enum": ["expense", "income"],
"description": "交易方向,收入必须是 income,支出必须是 expense"
"enum": ["expense", "income", "transfer"],
"description": "账单类型;明确转账时使用 transfer"
},
"amount": {
"type": "number",
@@ -53,6 +53,15 @@ public class AgentService(
"type": "string",
"description": "可选,例如微信支付、支付宝、现金"
},
"transferDirection": {
"type": "string",
"enum": ["in", "out"],
"description": "type=transfer 时必填,转入为 in,转出为 out"
},
"counterparty": {
"type": "string",
"description": "转账对方,可选"
},
"occurredAt": {
"type": "string",
"description": "可选,ISO 8601 时间;未提时间不要填写"
@@ -105,7 +114,7 @@ public class AgentService(
},
"type": {
"type": "string",
"enum": ["expense", "income"]
"enum": ["expense", "income", "transfer"]
},
"categoryName": {
"type": "string"
@@ -306,9 +315,9 @@ public class AgentService(
foreach (var bill in bills)
{
var category = await db.Categories.FirstAsync(
c => c.Id == bill.CategoryId && c.Type == bill.Type,
c => c.Id == bill.CategoryId && c.Type == bill.Type.CategoryType(bill.TransferDirection),
ct);
if (category.Type != bill.Type)
if (category.Type != bill.Type.CategoryType(bill.TransferDirection))
throw new InvalidOperationException("交易类型与分类类型不一致");
pending.Add(new Transaction
@@ -318,6 +327,8 @@ public class AgentService(
CategoryId = category.Id,
Category = category,
Type = bill.Type,
TransferDirection = bill.TransferDirection,
Counterparty = bill.Counterparty,
Amount = bill.Amount,
Note = NormalizeNote(bill.Note, sourceText, category.Name),
PaymentMethod = bill.PaymentMethod,
@@ -336,7 +347,7 @@ public class AgentService(
{
await db.SaveChangesAsync(ct);
await budgetPush.EvaluateAsync(userId, pending
.Where(transaction => transaction.Type == TransactionType.Expense)
.Where(transaction => transaction.Type.IsExpense(transaction.TransferDirection))
.Select(transaction => new BudgetExpenseChange(
transaction.LedgerId,
transaction.CategoryId,
@@ -361,9 +372,9 @@ public class AgentService(
items = created.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
type = transaction.Type.ToWire(),
transferDirection = transaction.TransferDirection.ToWire(),
transaction.Counterparty,
amount = transaction.Amount,
categoryName = transaction.Category.Name,
note = transaction.Note,
@@ -395,9 +406,20 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException(
"交易 type 必须是 incomeexpense"),
"交易 type 必须是 incomeexpense 或 transfer"),
};
TransferDirection? transferDirection = null;
if (type == TransactionType.Transfer)
{
transferDirection = RequiredString(item, "transferDirection") switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
};
}
if (!item.TryGetProperty("amount", out var amountNode) ||
!amountNode.TryGetDecimal(out var amount) ||
amount <= 0)
@@ -411,7 +433,7 @@ public class AgentService(
if (note.Length > 30) note = note[..30];
var category = await ResolveCategoryAsync(
userId,
type,
type.CategoryType(transferDirection),
categoryName,
ct);
@@ -447,7 +469,9 @@ public class AgentService(
string.IsNullOrWhiteSpace(note) ? category.Name : note,
amount,
paymentMethod,
occurredAt));
occurredAt,
transferDirection,
OptionalString(item, "counterparty", 100)));
}
return bills;
}
@@ -470,18 +494,20 @@ public class AgentService(
.ToListAsync(ct);
var income = transactions
.Where(t => t.Type == TransactionType.Income)
.Where(t => t.Type.IsIncome(t.TransferDirection))
.Sum(t => t.Amount);
var expense = transactions
.Where(t => t.Type == TransactionType.Expense)
.Where(t => t.Type.IsExpense(t.TransferDirection))
.Sum(t => t.Amount);
var categories = transactions
.GroupBy(t => new { t.Type, t.Category.Name })
.GroupBy(t => new
{
EffectiveType = t.Type.IsIncome(t.TransferDirection) ? "income" : "expense",
t.Category.Name,
})
.Select(group => new
{
type = group.Key.Type == TransactionType.Income
? "income"
: "expense",
type = group.Key.EffectiveType,
categoryName = group.Key.Name,
amount = group.Sum(t => t.Amount),
})
@@ -524,6 +550,7 @@ public class AgentService(
{
"income" => TransactionType.Income,
"expense" => TransactionType.Expense,
"transfer" => TransactionType.Transfer,
_ => throw new InvalidOperationException("筛选类型无效"),
};
query = query.Where(t => t.Type == type);
@@ -552,9 +579,9 @@ public class AgentService(
items = transactions.Select(transaction => new
{
id = transaction.Id,
type = transaction.Type == TransactionType.Income
? "income"
: "expense",
type = transaction.Type.ToWire(),
transferDirection = transaction.TransferDirection.ToWire(),
transaction.Counterparty,
transaction.Amount,
categoryName = transaction.Category.Name,
transaction.Note,
@@ -605,7 +632,9 @@ public class AgentService(
var expenses = await db.Transactions
.Where(t => t.UserId == userId &&
t.LedgerId == ledgerId &&
t.Type == TransactionType.Expense &&
(t.Type == TransactionType.Expense ||
t.Type == TransactionType.Transfer &&
t.TransferDirection == TransferDirection.Out) &&
t.OccurredAt >= start &&
t.OccurredAt < end)
.Select(t => new { t.CategoryId, t.Amount })
@@ -687,7 +716,9 @@ public class AgentService(
private static object ToToolItem(ParsedBill bill) => new
{
type = bill.Type == TransactionType.Income ? "income" : "expense",
type = bill.Type.ToWire(),
transferDirection = bill.TransferDirection.ToWire(),
bill.Counterparty,
bill.Amount,
bill.CategoryName,
bill.Note,
@@ -706,6 +737,15 @@ public class AgentService(
return node.GetString()!;
}
private static string? OptionalString(JsonElement root, string name, int maxLength)
{
if (!root.TryGetProperty(name, out var node) || node.ValueKind != JsonValueKind.String)
return null;
var value = node.GetString()?.Trim();
if (string.IsNullOrEmpty(value)) return null;
return value.Length <= maxLength ? value : value[..maxLength];
}
private static (DateTime Start, DateTime End, string Label) ResolvePeriod(
string period)
{
+12 -4
View File
@@ -12,8 +12,10 @@ public record ParsedBill(
string CategoryName,
string Note,
decimal Amount,
string? PaymentMethod,
DateTime? OccurredAt = null);
string? PaymentMethod,
DateTime? OccurredAt = null,
TransferDirection? TransferDirection = null,
string? Counterparty = null);
public record IntentResult(string Kind, ParsedBill? Bill); // bill | query | chat
@@ -67,13 +69,19 @@ 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 action = bill.Type switch
{
TransactionType.Income => "收入",
TransactionType.Transfer when bill.TransferDirection == TransferDirection.In => "转入",
TransactionType.Transfer => "转出",
_ => "支出",
};
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.Type.IsIncome(bill.TransferDirection)
? $"收入到账!{bill.CategoryName} ¥{bill.Amount:F2},钱包回血啦"
: $"记好了!{bill.CategoryName} ¥{bill.Amount:F2},这笔支出我帮你盯着",
};
@@ -56,7 +56,9 @@ public sealed class BudgetPushService(AppDbContext db)
var spent = await db.Transactions
.Where(transaction => transaction.UserId == userId &&
transaction.LedgerId == group.Key.LedgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= start && transaction.OccurredAt < end)
.GroupBy(transaction => transaction.CategoryId)
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
@@ -75,7 +75,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= currentStart &&
transaction.OccurredAt < currentEnd)
.SumAsync(transaction => transaction.Amount, ct);
@@ -179,7 +181,9 @@ public sealed class BudgetRecommendationService(
.Where(transaction =>
transaction.UserId == userId &&
transaction.LedgerId == ledgerId &&
transaction.Type == TransactionType.Expense &&
(transaction.Type == TransactionType.Expense ||
transaction.Type == TransactionType.Transfer &&
transaction.TransferDirection == TransferDirection.Out) &&
transaction.OccurredAt >= historyStart &&
transaction.OccurredAt < currentEnd)
.Select(transaction => new
+13 -4
View File
@@ -50,8 +50,10 @@ public record ImageParseResult(
decimal Amount,
string CategoryName,
string? PaymentMethod,
string Note,
DateTime? OccurredAt);
string Note,
DateTime? OccurredAt,
string? TransferDirection = null,
string? Counterparty = null);
public record RecognitionBatchModelCandidate(
string CandidateId,
@@ -65,7 +67,12 @@ public record RecognitionBatchModelCandidate(
string RecognitionKind,
string? CategoryHint,
string Confidence,
IReadOnlyList<string> EvidenceIds);
IReadOnlyList<string> EvidenceIds,
string? TransferDirection,
string? Counterparty,
string? ProviderTransactionId,
string? RecognitionOccurrenceId,
string? IdentityConfidence);
public record RecognitionBatchModelEvidence(
string EvidenceId,
@@ -95,7 +102,9 @@ public record RecognitionBatchModelAction(
string? Note,
DateTime? OccurredAt,
double Confidence,
string Reason);
string Reason,
string? TransferDirection,
string? Counterparty);
public class NullLlmClient : ILlmClient
{
@@ -37,10 +37,11 @@ public partial class OpenAiVisionClient : ILlmClient
CancellationToken ct = default)
{
if (!IsEnabled) return null;
const string prompt =
"你是记账意图识别器。只返回 JSON:" +
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"," +
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
const string prompt =
"你是记账意图识别器。只返回 JSON:" +
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
"\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
@@ -62,8 +63,8 @@ public partial class OpenAiVisionClient : ILlmClient
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
{"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
type expenseincome transfertransfer transferDirection=in|outcounterparty
///////////
//////
note
@@ -181,11 +182,16 @@ public partial class OpenAiVisionClient : ILlmClient
categoryHint = candidate.CategoryHint,
confidence = candidate.Confidence,
evidenceIds = candidate.EvidenceIds,
transferDirection = candidate.TransferDirection,
counterparty = candidate.Counterparty,
providerTransactionId = candidate.ProviderTransactionId,
recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
identityConfidence = candidate.IdentityConfidence,
}),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var systemPrompt = $$"""
你是支付结果批次对账器。只返回 JSON:
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
{{candidateJson}}
{{string.Join('/', input.ExpenseCategories)}}
{{string.Join('/', input.IncomeCategories)}}
@@ -193,7 +199,7 @@ public partial class OpenAiVisionClient : ILlmClient
flowSessionId 使
flowSessionId drop
evidenceId create evidenceId
update/create type expense incomeamount 0
update/create type expenseincome transferamount 0transfer transferDirection=in|out
沿reason 40
""";
var messages = new List<object>();
@@ -331,7 +337,9 @@ public partial class OpenAiVisionClient : ILlmClient
ReadText(item, "note", "merchant")?.Trim(),
ReadOccurredAt(item),
confidence,
reason.Length > 80 ? reason[..80] : reason));
reason.Length > 80 ? reason[..80] : reason,
ReadText(item, "transferDirection", "transfer_direction")?.Trim().ToLowerInvariant(),
ReadText(item, "counterparty")?.Trim()));
}
return results;
}
@@ -485,8 +493,9 @@ public partial class OpenAiVisionClient : ILlmClient
var rawType = ReadText(item, "type", "transactionType", "direction");
var type = rawType?.Trim().ToLowerInvariant() switch
{
"income" or "收入" or "入账" => "income",
"expense" or "支出" or "出账" => "expense",
"income" or "收入" or "入账" => "income",
"expense" or "支出" or "出账" => "expense",
"transfer" or "转账" => "transfer",
_ => null,
};
if (type is null) return;
@@ -508,7 +517,18 @@ public partial class OpenAiVisionClient : ILlmClient
"description",
"title",
"counterparty");
var occurredAt = ReadOccurredAt(item);
var occurredAt = ReadOccurredAt(item);
var transferDirection = type == "transfer"
? ReadText(item, "transferDirection", "transfer_direction", "direction")
?.Trim().ToLowerInvariant() switch
{
"in" or "转入" => "in",
"out" or "转出" => "out",
_ => null,
}
: null;
if (type == "transfer" && transferDirection is null) return;
var counterparty = ReadText(item, "counterparty", "merchant")?.Trim();
results.Add(new ImageParseResult(
type,
@@ -516,7 +536,9 @@ public partial class OpenAiVisionClient : ILlmClient
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
occurredAt));
occurredAt,
transferDirection,
string.IsNullOrWhiteSpace(counterparty) ? null : counterparty));
}
private static DateTime? ReadOccurredAt(JsonElement item)
@@ -959,12 +981,25 @@ public partial class OpenAiVisionClient : ILlmClient
? 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;
if (typeText is null ||
typeText is not ("income" or "expense" or "transfer"))
return new IntentResult("chat", null);
var type = typeText switch
{
"income" => TransactionType.Income,
"transfer" => TransactionType.Transfer,
_ => TransactionType.Expense,
};
var transferDirection = type == TransactionType.Transfer
? ReadText(document, "transferDirection", "transfer_direction") switch
{
"in" => TransferDirection.In,
"out" => TransferDirection.Out,
_ => (TransferDirection?)null,
}
: null;
if (type == TransactionType.Transfer && transferDirection is null)
return new IntentResult("chat", null);
var category = document.TryGetProperty(
"categoryName",
out var categoryNode)
@@ -983,9 +1018,12 @@ public partial class OpenAiVisionClient : ILlmClient
type,
0,
category,
note,
amount,
null));
note,
amount,
null,
null,
transferDirection,
ReadText(document, "counterparty")?.Trim()));
}
catch
{