feat: reorganize admin llm settings

This commit is contained in:
2026-08-22 22:06:01 +08:00
parent 78c42eff53
commit f8f0b3c4fd
17 changed files with 1019 additions and 432 deletions
@@ -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);