feat: reorganize admin llm settings
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using System.Security.Cryptography;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
public sealed class LlmSecretProtectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
|
||||
{
|
||||
var encryptionKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:EncryptionKey"] = encryptionKey,
|
||||
})
|
||||
.Build();
|
||||
var protector = new LlmSecretProtector(configuration);
|
||||
|
||||
var encrypted = protector.Protect("sk-test-secret-1234");
|
||||
|
||||
Assert.DoesNotContain("sk-test-secret-1234", encrypted);
|
||||
Assert.Equal("sk-test-secret-1234", protector.Unprotect(encrypted));
|
||||
Assert.Equal("••••1234", LlmSecretProtector.Mask("sk-test-secret-1234"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protect_RejectsMissingEncryptionKey()
|
||||
{
|
||||
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => protector.Protect("sk-test"));
|
||||
|
||||
Assert.Contains("Secrets__EncryptionKey", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_UseStableGenerationParameters()
|
||||
{
|
||||
Assert.Equal("1024", AppConfigDefaults.Values["llm.max_tokens"]);
|
||||
Assert.Equal("0.7", AppConfigDefaults.Values["llm.temperature"]);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(AppDbContext db) : ControllerBase
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(
|
||||
AppDbContext db,
|
||||
LlmSecretProtector llmSecrets,
|
||||
OpenAiVisionClient llmClient) : ControllerBase
|
||||
{
|
||||
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
|
||||
|
||||
@@ -38,9 +41,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
|
||||
cfg.Value = req.Value;
|
||||
cfg.Version++;
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
[HttpPost("configs")]
|
||||
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
|
||||
@@ -51,9 +56,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
|
||||
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
|
||||
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
|
||||
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
|
||||
@@ -71,9 +78,150 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
|
||||
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
|
||||
[HttpGet("llm/settings")]
|
||||
public async Task<IActionResult> GetLlmSettings()
|
||||
{
|
||||
var values = await db.AppConfigs
|
||||
.Where(config => config.Key.StartsWith("llm."))
|
||||
.ToDictionaryAsync(config => config.Key, config => config.Value);
|
||||
string Read(string key) => values.GetValueOrDefault(
|
||||
key,
|
||||
AppConfigDefaults.Values[key]);
|
||||
|
||||
var encrypted = values.GetValueOrDefault(LlmSecretProtector.ConfigKey);
|
||||
var source = "none";
|
||||
var masked = "";
|
||||
if (llmSecrets.TryUnprotect(encrypted, out var databaseKey))
|
||||
{
|
||||
source = "database";
|
||||
masked = LlmSecretProtector.Mask(databaseKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var environmentKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(environmentKey))
|
||||
{
|
||||
source = "environment";
|
||||
masked = LlmSecretProtector.Mask(environmentKey);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
protocol = Read("llm.protocol"),
|
||||
baseUrl = Read("llm.base_url"),
|
||||
model = Read("llm.model"),
|
||||
maxTokens = int.TryParse(Read("llm.max_tokens"), out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096) : 1024,
|
||||
temperature = double.TryParse(
|
||||
Read("llm.temperature"),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2) : 0.7,
|
||||
apiKey = new
|
||||
{
|
||||
configured = source != "none",
|
||||
masked,
|
||||
source,
|
||||
canManage = AdminRequestContext.Principal(HttpContext)?.Role ==
|
||||
AdminRoles.SuperAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("llm/settings")]
|
||||
public async Task<IActionResult> UpdateLlmSettings(
|
||||
[FromBody] UpdateLlmSettingsRequest request)
|
||||
{
|
||||
var protocol = request.Protocol.Trim().ToLowerInvariant();
|
||||
if (protocol is not ("chat_completions" or "responses" or "messages"))
|
||||
return BadRequest(new { error = "protocol_invalid", detail = "API 协议不受支持" });
|
||||
if (!Uri.TryCreate(request.BaseUrl, UriKind.Absolute, out var baseUri) ||
|
||||
baseUri.Scheme is not ("http" or "https"))
|
||||
return BadRequest(new { error = "base_url_invalid", detail = "API 地址必须是有效的 HTTP 或 HTTPS 地址" });
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "model_required", detail = "模型名称不能为空" });
|
||||
if (request.MaxTokens is < 64 or > 4096)
|
||||
return BadRequest(new { error = "max_tokens_invalid", detail = "最大输出 Token 必须在 64 到 4096 之间" });
|
||||
if (request.Temperature is < 0 or > 2)
|
||||
return BadRequest(new { error = "temperature_invalid", detail = "温度必须在 0 到 2 之间" });
|
||||
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["llm.protocol"] = protocol,
|
||||
["llm.base_url"] = request.BaseUrl.Trim().TrimEnd('/'),
|
||||
["llm.model"] = request.Model.Trim(),
|
||||
["llm.max_tokens"] = request.MaxTokens.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
["llm.temperature"] = request.Temperature.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
await UpsertConfigsAsync(values);
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPut("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> UpdateLlmApiKey([FromBody] UpdateLlmApiKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ApiKey))
|
||||
return BadRequest(new { error = "api_key_required", detail = "API Key 不能为空" });
|
||||
if (request.ApiKey.Trim().Length > 8192)
|
||||
return BadRequest(new { error = "api_key_too_long", detail = "API Key 长度异常" });
|
||||
|
||||
string protectedValue;
|
||||
try { protectedValue = llmSecrets.Protect(request.ApiKey); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "密钥加密尚未配置",
|
||||
detail: exception.Message);
|
||||
}
|
||||
await UpsertConfigsAsync(new Dictionary<string, string>
|
||||
{
|
||||
[LlmSecretProtector.ConfigKey] = protectedValue,
|
||||
});
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpDelete("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> DeleteLlmApiKey()
|
||||
{
|
||||
var config = await db.AppConfigs.FirstOrDefaultAsync(
|
||||
item => item.Key == LlmSecretProtector.ConfigKey);
|
||||
if (config is not null)
|
||||
{
|
||||
db.AppConfigs.Remove(config);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPost("llm/test")]
|
||||
public async Task<IActionResult> TestLlm()
|
||||
{
|
||||
llmClient.InvalidateConfiguration();
|
||||
var (ok, error) = await llmClient.TestConnectionAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ok,
|
||||
error = ok ? (string?)null : error,
|
||||
detail = ok ? "LLM 连接正常" : error,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
|
||||
[HttpPost("configs/init")]
|
||||
public async Task<IActionResult> InitConfigs()
|
||||
{
|
||||
var added = await AppConfigDefaults.EnsureAsync(db);
|
||||
return Ok(new { added, total = AppConfigDefaults.Values.Count });
|
||||
}
|
||||
|
||||
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
|
||||
[HttpPost("categories")]
|
||||
@@ -264,17 +412,53 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
|
||||
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
|
||||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task UpsertConfigsAsync(IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var keys = values.Keys.ToArray();
|
||||
var existing = await db.AppConfigs
|
||||
.Where(config => keys.Contains(config.Key))
|
||||
.ToDictionaryAsync(config => config.Key);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (existing.TryGetValue(key, out var config))
|
||||
{
|
||||
config.Value = value;
|
||||
config.Version++;
|
||||
config.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateConfigRequest(string Value);
|
||||
public record CreateConfigRequest(string Key, string Value);
|
||||
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
|
||||
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpdateLlmSettingsRequest(
|
||||
string Protocol,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
int MaxTokens,
|
||||
double Temperature);
|
||||
public record UpdateLlmApiKeyRequest(string ApiKey);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddScoped<AdminSessionService>();
|
||||
builder.Services.AddScoped<AdminBootstrapService>();
|
||||
builder.Services.AddSingleton<LlmSecretProtector>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
@@ -165,6 +166,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await AppConfigDefaults.EnsureAsync(db);
|
||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||
if (app.Environment.IsDevelopment())
|
||||
await DbSeeder.SeedAsync(db);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class AppConfigDefaults
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> Values =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["brand.app_name"] = "记之",
|
||||
["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱",
|
||||
["brand.logo_url"] = "",
|
||||
["llm.protocol"] = "responses",
|
||||
["llm.base_url"] = "https://api.openai.com/v1",
|
||||
["llm.model"] = "gpt-4o-mini",
|
||||
["llm.max_tokens"] = "1024",
|
||||
["llm.temperature"] = "0.7",
|
||||
["limit.daily_ai_messages"] = "200",
|
||||
["limit.daily_ai_messages_per_user"] = "50",
|
||||
["limit.max_monthly_budget"] = "99999999",
|
||||
["feature.ocr_enabled"] = "true",
|
||||
["feature.voice_enabled"] = "true",
|
||||
["feature.ai_auto_book"] = "true",
|
||||
["feature.sticker_enabled"] = "true",
|
||||
["feature.screenshot_bookkeeping_enabled"] = "true",
|
||||
["permission.default.ai_enabled"] = "true",
|
||||
["quota.default_ai_chat_limit"] = "50",
|
||||
["quota.default_ai_chat_period"] = "day",
|
||||
["system.default_ledger_name"] = "日常账本",
|
||||
["system.max_ledgers_per_user"] = "10",
|
||||
};
|
||||
|
||||
public static async Task<int> EnsureAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.AppConfigs
|
||||
.Select(config => config.Key)
|
||||
.ToListAsync(ct);
|
||||
var keys = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var now = DateTime.UtcNow;
|
||||
var added = 0;
|
||||
foreach (var (key, value) in Values)
|
||||
{
|
||||
if (keys.Contains(key)) continue;
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
if (added > 0) await db.SaveChangesAsync(ct);
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class LlmSecretProtector(IConfiguration configuration)
|
||||
{
|
||||
public const string ConfigKey = "llm.api_key_encrypted";
|
||||
private const string Prefix = "v1";
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("API Key 不能为空", nameof(value));
|
||||
var key = ReadEncryptionKey();
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var plaintext = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
return string.Join(':', Prefix,
|
||||
Convert.ToBase64String(nonce),
|
||||
Convert.ToBase64String(ciphertext),
|
||||
Convert.ToBase64String(tag));
|
||||
}
|
||||
|
||||
public string Unprotect(string protectedValue)
|
||||
{
|
||||
var parts = protectedValue.Split(':');
|
||||
if (parts.Length != 4 || parts[0] != Prefix)
|
||||
throw new CryptographicException("不支持的密钥密文格式");
|
||||
var nonce = Convert.FromBase64String(parts[1]);
|
||||
var ciphertext = Convert.FromBase64String(parts[2]);
|
||||
var tag = Convert.FromBase64String(parts[3]);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(ReadEncryptionKey(), tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public bool TryUnprotect(string? protectedValue, out string value)
|
||||
{
|
||||
value = "";
|
||||
if (string.IsNullOrWhiteSpace(protectedValue)) return false;
|
||||
try
|
||||
{
|
||||
value = Unprotect(protectedValue);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or FormatException or
|
||||
CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Mask(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
var suffixLength = Math.Min(4, value.Length);
|
||||
return $"••••{value[^suffixLength..]}";
|
||||
}
|
||||
|
||||
private byte[] ReadEncryptionKey()
|
||||
{
|
||||
var raw = configuration["Secrets:EncryptionKey"];
|
||||
byte[]? key = null;
|
||||
try { key = string.IsNullOrWhiteSpace(raw) ? null : Convert.FromBase64String(raw); }
|
||||
catch (FormatException) { }
|
||||
if (key?.Length != 32)
|
||||
throw new InvalidOperationException(
|
||||
"请通过 Secrets__EncryptionKey 配置 base64 编码的 32 字节密钥后再保存 API Key");
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,26 @@ 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 readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private readonly LlmSecretProtector _secretProtector;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private double _temperature = 0.7;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
private static readonly object _lk = new();
|
||||
|
||||
public OpenAiVisionClient(
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
LlmSecretProtector secretProtector,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
{
|
||||
_sf = sf;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_logger = logger;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_secretProtector = secretProtector;
|
||||
_logger = logger;
|
||||
}
|
||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||
|
||||
@@ -679,14 +683,22 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
|
||||
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(
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
|
||||
try
|
||||
{
|
||||
if (_protocol != "responses")
|
||||
{
|
||||
var reply = await L(
|
||||
"你正在执行连接测试,只回复 OK。",
|
||||
"测试连接",
|
||||
ct);
|
||||
return string.IsNullOrWhiteSpace(reply)
|
||||
? (false, "模型没有返回内容")
|
||||
: (true, null);
|
||||
}
|
||||
var tool = new AgentToolDefinition(
|
||||
"diagnostic_echo",
|
||||
"连接测试时必须调用的无副作用工具",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
@@ -716,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
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);
|
||||
}
|
||||
await SA(BuildBody(msgs, _maxTokens, _temperature), onToken, ct);
|
||||
}
|
||||
|
||||
public void InvalidateConfiguration()
|
||||
{
|
||||
lock (_lk) _last = DateTime.MinValue;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
@@ -728,23 +745,36 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
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");
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = _secretProtector.TryUnprotect(
|
||||
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
|
||||
out var protectedApiKey)
|
||||
? protectedApiKey
|
||||
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
config.GetValueOrDefault("llm.base_url") ??
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
"https://api.openai.com/v1").TrimEnd('/');
|
||||
_model = config.GetValueOrDefault("llm.model") ??
|
||||
Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
"gpt-4o-mini";
|
||||
_protocol = config.GetValueOrDefault("llm.protocol") ??
|
||||
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
"responses";
|
||||
_maxTokens = int.TryParse(
|
||||
config.GetValueOrDefault("llm.max_tokens"),
|
||||
out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_last = DateTime.UtcNow;
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_temperature = double.TryParse(
|
||||
config.GetValueOrDefault("llm.temperature"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2)
|
||||
: 0.7;
|
||||
_last = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -754,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
}
|
||||
|
||||
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; }
|
||||
{ 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, _temperature), 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 } };
|
||||
|
||||
@@ -99,7 +99,7 @@ public static class DbSeeder
|
||||
|
||||
new AppConfig { Key = "llm.model", Value = "gpt-4o-mini", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.max_tokens", Value = "1024", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.8", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.7", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages", Value = "200", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
|
||||
|
||||
Reference in New Issue
Block a user