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
@@ -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
{