Add transfer tracking and secure admin access
This commit is contained in:
@@ -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 必须是 income 或 expense"),
|
||||
"交易 type 必须是 income、expense 或 transfer"),
|
||||
};
|
||||
TransferDirection? transferDirection = null;
|
||||
if (type == TransactionType.Transfer)
|
||||
{
|
||||
transferDirection = RequiredString(item, "transferDirection") switch
|
||||
{
|
||||
"in" => TransferDirection.In,
|
||||
"out" => TransferDirection.Out,
|
||||
_ => throw new InvalidOperationException("转账方向必须是 in 或 out"),
|
||||
};
|
||||
}
|
||||
if (!item.TryGetProperty("amount", out var amountNode) ||
|
||||
!amountNode.TryGetDecimal(out var amount) ||
|
||||
amount <= 0)
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 只能是 expense、income 或 transfer;transfer 必须同时返回 transferDirection=in|out,counterparty 尽量填写转账对方。
|
||||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||||
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 或 income,amount 必须大于 0。
|
||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user