diff --git a/admin-web/src/App.vue b/admin-web/src/App.vue index fa91f0f..4d42e5a 100644 --- a/admin-web/src/App.vue +++ b/admin-web/src/App.vue @@ -5,7 +5,7 @@ import { adminAuth } from './auth' import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined, GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined, NotificationOutlined, SafetyCertificateOutlined, AuditOutlined, - LogoutOutlined } from '@ant-design/icons-vue' + LogoutOutlined, CloudServerOutlined, FundOutlined } from '@ant-design/icons-vue' const router = useRouter() const route = useRoute() @@ -14,20 +14,29 @@ const selectedKeys = ref([String(route.name)]) watch(() => route.name, (n) => { selectedKeys.value = [String(n)] }) -const nav = computed(() => [ - { key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' }, - { key: 'Settings', icon: ControlOutlined, label: '系统设置' }, - { key: 'Configs', icon: SettingOutlined, label: '品牌配置' }, - { key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' }, - { key: 'Personas', icon: SmileOutlined, label: 'AI 性格' }, - { key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' }, - { key: 'Stickers', icon: PictureOutlined, label: '表情包库' }, - { key: 'Users', icon: TeamOutlined, label: '用户管理' }, - { key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' }, - ...(adminAuth.identity.value?.role === 'super_admin' ? [ +const navGroups = computed(() => [ + { label: '概览', items: [ + { key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' }, + ] }, + { label: 'AI 配置', items: [ + { key: 'ModelService', icon: CloudServerOutlined, label: '模型服务' }, + { key: 'Personas', icon: SmileOutlined, label: 'AI 性格' }, + { key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' }, + { key: 'Stickers', icon: PictureOutlined, label: '表情包库' }, + ] }, + { label: '产品配置', items: [ + { key: 'ProductBasic', icon: SettingOutlined, label: '品牌与基础设置' }, + { key: 'FeatureLimits', icon: ControlOutlined, label: '功能与额度' }, + { key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' }, + ] }, + { label: '运营', items: [ + { key: 'Users', icon: TeamOutlined, label: '用户管理' }, + { key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' }, + ] }, + ...(adminAuth.identity.value?.role === 'super_admin' ? [{ label: '安全', items: [ { key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' }, { key: 'Audit', icon: AuditOutlined, label: '操作审计' }, - ] : []), + ] }] : []), ]) watch(adminAuth.identity, value => { @@ -43,20 +52,23 @@ async function logout() { + + diff --git a/admin-web/src/api/index.ts b/admin-web/src/api/index.ts index ae0fbd8..d817d85 100644 --- a/admin-web/src/api/index.ts +++ b/admin-web/src/api/index.ts @@ -56,6 +56,16 @@ export const api = { createSysCategory: (d: { name: string; iconKey: string; type: string }) => http.post('/api/admin/categories', d).then(r => r.data), updateSysCategory: (id: number, d: { name: string; iconKey: string; type: string }) => http.put(`/api/admin/categories/${id}`, d).then(r => r.data), deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`), + llmSettings: () => http.get('/api/admin/llm/settings').then(r => r.data), + updateLlmSettings: (data: { + protocol: string + baseUrl: string + model: string + maxTokens: number + temperature: number + }) => http.put('/api/admin/llm/settings', data).then(r => r.data), + updateLlmApiKey: (apiKey: string) => http.put('/api/admin/llm/api-key', { apiKey }).then(r => r.data), + deleteLlmApiKey: () => http.delete('/api/admin/llm/api-key').then(r => r.data), testLlm: () => http.post('/api/admin/llm/test').then(r => r.data), pushCampaigns: (params: { page: number; limit: number }) => http.get('/api/admin/push/campaigns', { params }).then(r => r.data), estimatePushCampaign: (data: any) => http.post('/api/admin/push/campaigns/estimate', data).then(r => r.data), diff --git a/admin-web/src/composables/useAdminConfigs.ts b/admin-web/src/composables/useAdminConfigs.ts new file mode 100644 index 0000000..518caed --- /dev/null +++ b/admin-web/src/composables/useAdminConfigs.ts @@ -0,0 +1,85 @@ +import { onMounted, ref } from 'vue' +import { message } from 'ant-design-vue' +import { api } from '../api' + +interface Config { + id: number + key: string + value: string + version: number +} + +export function useAdminConfigs(defaults: Record) { + const configs = ref>({}) + const loading = ref(true) + const saving = ref(false) + + onMounted(load) + + async function load() { + loading.value = true + try { + const list = await api.configs() as Config[] + const map: Record = {} + for (const config of list) map[config.key] = config + for (const [key, value] of Object.entries(defaults)) { + if (!map[key]) map[key] = { id: 0, key, value, version: 0 } + } + configs.value = map + } catch (error: any) { + message.error(readError(error, '配置加载失败')) + } finally { + loading.value = false + } + } + + function value(key: string) { + return configs.value[key]?.value ?? defaults[key] ?? '' + } + + function numberValue(key: string) { + const parsed = Number(value(key)) + return Number.isFinite(parsed) ? parsed : Number(defaults[key] ?? 0) + } + + function boolValue(key: string) { + return value(key) === 'true' + } + + function setValue(key: string, next: string | number | boolean | null) { + const normalized = String(next ?? '') + const current = configs.value[key] + if (current) current.value = normalized + else configs.value[key] = { id: 0, key, value: normalized, version: 0 } + } + + async function save(keys: string[]) { + saving.value = true + try { + for (const key of keys) { + const config = configs.value[key] + if (!config) continue + const saved = config.id > 0 + ? await api.updateConfig(config.id, config.value) + : await api.createConfig(config.key, config.value) + config.id = saved.id + config.version = saved.version + } + message.success('配置已保存') + } catch (error: any) { + message.error(readError(error, '配置保存失败')) + throw error + } finally { + saving.value = false + } + } + + return { loading, saving, value, numberValue, boolValue, setValue, save, load } +} + +export function readError(error: any, fallback: string) { + return error?.response?.data?.detail || + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || fallback +} diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts index b4d788e..6339121 100644 --- a/admin-web/src/router/index.ts +++ b/admin-web/src/router/index.ts @@ -8,8 +8,11 @@ const router = createRouter({ { path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') }, { path: '/', redirect: '/dashboard' }, { path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') }, - { path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') }, - { path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') }, + { path: '/settings', redirect: '/ai/model' }, + { path: '/configs', redirect: '/product/basic' }, + { path: '/ai/model', name: 'ModelService', component: () => import('../views/ModelService.vue') }, + { path: '/product/basic', name: 'ProductBasic', component: () => import('../views/ProductBasic.vue') }, + { path: '/product/features', name: 'FeatureLimits', component: () => import('../views/FeatureLimits.vue') }, { path: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') }, { path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') }, { path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') }, diff --git a/admin-web/src/views/Configs.vue b/admin-web/src/views/Configs.vue deleted file mode 100644 index 3a8b411..0000000 --- a/admin-web/src/views/Configs.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - \ No newline at end of file diff --git a/admin-web/src/views/FeatureLimits.vue b/admin-web/src/views/FeatureLimits.vue new file mode 100644 index 0000000..2e76a5e --- /dev/null +++ b/admin-web/src/views/FeatureLimits.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/admin-web/src/views/ModelService.vue b/admin-web/src/views/ModelService.vue new file mode 100644 index 0000000..1c649f6 --- /dev/null +++ b/admin-web/src/views/ModelService.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/admin-web/src/views/ProductBasic.vue b/admin-web/src/views/ProductBasic.vue new file mode 100644 index 0000000..64695a0 --- /dev/null +++ b/admin-web/src/views/ProductBasic.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/admin-web/src/views/Settings.vue b/admin-web/src/views/Settings.vue deleted file mode 100644 index ae711c8..0000000 --- a/admin-web/src/views/Settings.vue +++ /dev/null @@ -1,234 +0,0 @@ - - - diff --git a/backend/MiaoJiZhang.Api.Tests/LlmConfigurationTests.cs b/backend/MiaoJiZhang.Api.Tests/LlmConfigurationTests.cs new file mode 100644 index 0000000..087586c --- /dev/null +++ b/backend/MiaoJiZhang.Api.Tests/LlmConfigurationTests.cs @@ -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 + { + ["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( + () => 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"]); + } +} diff --git a/backend/MiaoJiZhang.Api/Controllers/AdminController.cs b/backend/MiaoJiZhang.Api/Controllers/AdminController.cs index 607516e..7a891b6 100644 --- a/backend/MiaoJiZhang.Api/Controllers/AdminController.cs +++ b/backend/MiaoJiZhang.Api/Controllers/AdminController.cs @@ -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 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 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 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 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 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 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 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 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 + { + ["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 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 + { + [LlmSecretProtector.ConfigKey] = protectedValue, + }); + llmClient.InvalidateConfiguration(); + return await GetLlmSettings(); + } + + [HttpDelete("llm/api-key")] + [AdminAuth(AdminRoles.SuperAdmin)] + public async Task 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 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 InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary { ["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 InitConfigs() + { + var added = await AppConfigDefaults.EnsureAsync(db); + return Ok(new { added, total = AppConfigDefaults.Values.Count }); + } [HttpGet("categories")] public async Task 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 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 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); diff --git a/backend/MiaoJiZhang.Api/Program.cs b/backend/MiaoJiZhang.Api/Program.cs index 22e4419..a3a3461 100644 --- a/backend/MiaoJiZhang.Api/Program.cs +++ b/backend/MiaoJiZhang.Api/Program.cs @@ -63,6 +63,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient("LlmClient"); @@ -165,6 +166,7 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); + await AppConfigDefaults.EnsureAsync(db); await scope.ServiceProvider.GetRequiredService().EnsureAsync(); if (app.Environment.IsDevelopment()) await DbSeeder.SeedAsync(db); diff --git a/backend/MiaoJiZhang.Api/Services/AppConfigDefaults.cs b/backend/MiaoJiZhang.Api/Services/AppConfigDefaults.cs new file mode 100644 index 0000000..ca887d5 --- /dev/null +++ b/backend/MiaoJiZhang.Api/Services/AppConfigDefaults.cs @@ -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 Values = + new Dictionary(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 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; + } +} diff --git a/backend/MiaoJiZhang.Api/Services/LlmSecretProtector.cs b/backend/MiaoJiZhang.Api/Services/LlmSecretProtector.cs new file mode 100644 index 0000000..17c22e0 --- /dev/null +++ b/backend/MiaoJiZhang.Api/Services/LlmSecretProtector.cs @@ -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; + } +} diff --git a/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs b/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs index d4b7190..51fe256 100644 --- a/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs +++ b/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs @@ -13,22 +13,26 @@ namespace MiaoJiZhang.Api.Services; public partial class OpenAiVisionClient : ILlmClient { private readonly IServiceScopeFactory _sf; - private readonly HttpClient _http; - private readonly ILogger _logger; - private string? _baseUrl, _apiKey, _model, _protocol; - private int _maxTokens = 1024; + private readonly HttpClient _http; + private readonly ILogger _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 logger) + IServiceScopeFactory sf, + IHttpClientFactory hf, + LlmSecretProtector secretProtector, + ILogger 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( @@ -716,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient var msgs = new List(); 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(); - 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(); + 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 L(string sys, string user, CancellationToken ct) - { Load(); var msgs = new List(); 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(); 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 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 } }; diff --git a/backend/MiaoJiZhang.Infrastructure/Persistence/DbSeeder.cs b/backend/MiaoJiZhang.Infrastructure/Persistence/DbSeeder.cs index 5648ad7..fdea5a3 100644 --- a/backend/MiaoJiZhang.Infrastructure/Persistence/DbSeeder.cs +++ b/backend/MiaoJiZhang.Infrastructure/Persistence/DbSeeder.cs @@ -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 }, diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index c6d497e..297889d 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -44,7 +44,8 @@ ```bash cd backend export Admin__BootstrapUsername='admin' -export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters' +export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters' +export Secrets__EncryptionKey='base64-encoded-32-byte-key' dotnet build # 重启 powershell -Command "Get-Process dotnet | Stop-Process -Force" @@ -56,6 +57,17 @@ dotnet run --project MiaoJiZhang.Api 引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可 临时设置 `Admin__CookieSecure=false`。 +后台“AI 配置 → 模型服务”可以保存和替换 LLM API Key。实际 API Key 使用 AES-GCM +加密后写入配置表,服务端只需通过 `Secrets__EncryptionKey` 提供一个固定的 32 字节 +加密主密钥;页面和接口只显示 API Key 尾号。可使用 PowerShell 生成: + +```powershell +[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) +``` + +请将该值保存到部署平台的密钥管理中,不要提交到仓库。更换或丢失主密钥会导致后台已保存的 +LLM API Key 无法解密。旧的 `LLM_API_KEY` 仍作为回退配置;后台保存的密钥优先。 + ### 2. Admin Web ```powershell cd admin-web