using MiaoJiZhang.Api.Contracts; using MiaoJiZhang.Api.Services; using MiaoJiZhang.Domain.Entities; using MiaoJiZhang.Domain.Enums; using MiaoJiZhang.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace MiaoJiZhang.Api.Controllers; [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) }); } [HttpGet("configs")] public async Task ListConfigs() { var configs = await db.AppConfigs.OrderBy(c => c.Key).ToListAsync(); return Ok(configs.Select(c => new { c.Id, c.Key, Value = IsSecret(c.Key) ? "********" : c.Value, IsSecret = IsSecret(c.Key), c.Version, c.UpdatedAt, })); } [HttpPut("configs/{id:long}")] public async Task UpdateConfig(long id, [FromBody] UpdateConfigRequest req) { var cfg = await db.AppConfigs.FindAsync(id); if (cfg is null) return NotFound(); if (IsSecret(cfg.Key)) return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" }); cfg.Value = req.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) { if (string.IsNullOrWhiteSpace(req.Key)) return BadRequest(new { error = "key_required" }); if (IsSecret(req.Key)) return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量配置" }); 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(); 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()); [HttpPost("personas")] public async Task CreatePersona([FromBody] UpsertPersonaRequest req) { if (await db.AiPersonas.AnyAsync(p => p.Key == req.Key)) return Conflict(new { error = "key_exists" }); var p = new AiPersona { Key = req.Key, Name = req.Name, Description = req.Description, SampleLine = req.SampleLine, PromptTemplate = req.PromptTemplate, IsEnabled = true, Version = 1 }; db.AiPersonas.Add(p); await db.SaveChangesAsync(); return Ok(p); } [HttpPut("personas/{id:long}")] public async Task UpdatePersona(long id, [FromBody] UpsertPersonaRequest req) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); p.Key = req.Key; p.Name = req.Name; p.Description = req.Description; p.SampleLine = req.SampleLine; p.PromptTemplate = req.PromptTemplate; p.IsEnabled = req.IsEnabled; p.Version++; await db.SaveChangesAsync(); return Ok(p); } [HttpDelete("personas/{id:long}")] public async Task DeletePersona(long id) { var p = await db.AiPersonas.FindAsync(id); if (p is null) return NotFound(); db.AiPersonas.Remove(p); await db.SaveChangesAsync(); return NoContent(); } [HttpGet("avatars")] public async Task ListAvatars() => Ok(await db.AiAvatars.OrderBy(a => a.Key).ToListAsync()); [HttpPost("avatars")] public async Task CreateAvatar([FromBody] UpsertAvatarRequest req) { if (await db.AiAvatars.AnyAsync(a => a.Key == req.Key)) return Conflict(new { error = "key_exists" }); var a = new AiAvatar { Key = req.Key, DefaultName = req.Name, SpeechTic = req.SpeechTic, ImageUrl = req.ImageUrl, IsEnabled = true }; db.AiAvatars.Add(a); await db.SaveChangesAsync(); return Ok(a); } [HttpPut("avatars/{id:long}")] public async Task UpdateAvatar(long id, [FromBody] UpsertAvatarRequest req) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); a.Key = req.Key; a.DefaultName = req.Name; a.SpeechTic = req.SpeechTic; a.ImageUrl = req.ImageUrl; a.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(a); } [HttpDelete("avatars/{id:long}")] public async Task DeleteAvatar(long id) { var a = await db.AiAvatars.FindAsync(id); if (a is null) return NotFound(); db.AiAvatars.Remove(a); await db.SaveChangesAsync(); return NoContent(); } [HttpGet("stickers")] public async Task ListStickers() => Ok(await db.Stickers.OrderBy(s => s.GroupKey).ThenBy(s => s.Key).ToListAsync()); [HttpPost("stickers")] public async Task CreateSticker([FromBody] UpsertStickerRequest req) { if (await db.Stickers.AnyAsync(s => s.Key == req.Key)) return Conflict(new { error = "key_exists" }); var s = new Sticker { Key = req.Key, Label = req.Label, GroupKey = req.GroupKey, TriggerTags = req.TriggerTags, ImageUrl = req.ImageUrl, IsEnabled = true }; db.Stickers.Add(s); await db.SaveChangesAsync(); return Ok(s); } [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(); } [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 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")] public async Task CreateSystemCategory([FromBody] UpsertCategoryRequest req) { if (req.Type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var iconKey = CategoriesController.NormalizeIcon(req.IconKey); if (!CategoriesController.AllowedIcons.Contains(iconKey)) return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在")); var type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense; var maxSort = await db.Categories .Where(category => category.UserId == null && category.Type == type) .MaxAsync(category => (int?)category.SortOrder) ?? 0; var category = new Category { Type = type, Name = req.Name, IconKey = iconKey, SortOrder = maxSort + 1, }; db.Categories.Add(category); await db.SaveChangesAsync(); return Ok(category); } [HttpPut("categories/{id:long}")] public async Task UpdateSystemCategory(long id, [FromBody] UpsertCategoryRequest req) { if (req.Type is not ("income" or "expense")) return BadRequest(new ApiError("TYPE_INVALID", "收支类型必须是 expense 或 income")); var iconKey = CategoriesController.NormalizeIcon(req.IconKey); if (!CategoriesController.AllowedIcons.Contains(iconKey)) return BadRequest(new ApiError("ICON_INVALID", "分类图标不存在")); var category = await db.Categories.FirstOrDefaultAsync( item => item.Id == id && item.UserId == null); if (category is null) return NotFound(); category.Name = req.Name; category.IconKey = iconKey; category.Type = req.Type == "income" ? TransactionType.Income : TransactionType.Expense; await db.SaveChangesAsync(); return Ok(category); } [HttpDelete("categories/{id:long}")] public async Task DeleteSystemCategory(long id) { var cat = await db.Categories.FirstOrDefaultAsync(c => c.Id == id && c.UserId == null); if (cat is null) return NotFound(); cat.IsDeleted = true; await db.SaveChangesAsync(); return NoContent(); } [HttpGet("users")] public async Task ListUsers( [FromQuery] string? search, [FromQuery] bool? banned, [FromQuery] int page = 1, [FromQuery] int limit = 20) { var query = db.Users .Include(user => user.AiCompanion) .Include(user => user.FeaturePermissions) .AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(user => user.Username.Contains(search)); if (banned.HasValue) query = query.Where(user => user.IsBanned == banned.Value); var total = await query.CountAsync(); var users = await query .OrderByDescending(user => user.CreatedAt) .Skip((Math.Max(1, page) - 1) * Math.Clamp(limit, 1, 100)) .Take(Math.Clamp(limit, 1, 100)) .ToListAsync(); var ids = users.Select(user => user.Id).ToArray(); var transactionCounts = await db.Transactions .IgnoreQueryFilters() .Where(transaction => ids.Contains(transaction.UserId)) .GroupBy(transaction => transaction.UserId) .Select(group => new { UserId = group.Key, Count = group.Count() }) .ToDictionaryAsync(item => item.UserId, item => item.Count); var aiTransactionCounts = await db.Transactions .IgnoreQueryFilters() .Where(transaction => ids.Contains(transaction.UserId) && TransactionSourceRules.AiAssisted.Contains(transaction.Source)) .GroupBy(transaction => transaction.UserId) .Select(group => new { UserId = group.Key, Count = group.Count() }) .ToDictionaryAsync(item => item.UserId, item => item.Count); var list = users.Select(user => { var quota = AiChatQuotaService.GetStatus(user); return new { user.Id, user.Username, user.Nickname, AppMode = user.AppMode == AppMode.AiFirst ? "ai" : "normal", user.IsBanned, user.CreatedAt, user.LastLoginAt, user.AccountClosureRequestedAt, user.AccountClosureScheduledAt, AiEnabled = user.FeaturePermissions.Any(permission => permission.PermissionKey == FeaturePermissionKeys.Ai && permission.IsEnabled), AiChatLimit = quota.Limit, AiChatUsed = quota.Used, AiChatRemaining = quota.Remaining, AiChatPeriod = AiChatQuotaService.PeriodKey(quota.Period), AiChatResetAt = quota.ResetAt, Companion = user.AiCompanion == null ? null : new { user.AiCompanion.AvatarKey, user.AiCompanion.PersonaKey, user.AiCompanion.CustomName, }, TxCount = transactionCounts.GetValueOrDefault(user.Id), AiTxCount = aiTransactionCounts.GetValueOrDefault(user.Id), }; }).ToList(); return Ok(new { total, page, list }); } [HttpPut("users/{id:long}/ban")] public async Task ToggleBan(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); u.IsBanned = !u.IsBanned; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, u.IsBanned }); } [HttpPut("users/{id:long}/cancel-closure")] public async Task CancelClosure(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); if (!u.AccountClosureScheduledAt.HasValue) return Conflict(new ApiError("CLOSURE_NOT_PENDING", "账号未处于注销流程")); u.AccountClosureRequestedAt = null; u.AccountClosureScheduledAt = null; u.AuthVersion++; await db.SaveChangesAsync(); return Ok(new { u.Id, u.Username, cancelled = true }); } [HttpPut("users/{id:long}/permissions")] public async Task UpdatePermissions(long id, UpdateUserPermissionsRequest req) { var user = await db.Users.FindAsync(id); if (user is null) return NotFound(); var permission = await db.UserFeaturePermissions.FirstOrDefaultAsync(p => p.UserId == id && p.PermissionKey == FeaturePermissionKeys.Ai); var now = DateTime.UtcNow; if (permission is null) { permission = new UserFeaturePermission { UserId = id, PermissionKey = FeaturePermissionKeys.Ai, CreatedAt = now, }; db.UserFeaturePermissions.Add(permission); } permission.IsEnabled = req.Ai; permission.UpdatedAt = now; if (!req.Ai) user.AppMode = AppMode.Normal; await db.SaveChangesAsync(); return Ok(new { user.Id, permissions = new { ai = permission.IsEnabled } }); } [HttpPut("users/{id:long}/ai-quota")] public async Task UpdateAiChatQuota( long id, UpdateAiChatQuotaRequest req) { if (req.Limit is < 0 or > 1_000_000) return BadRequest(new ApiError( "AI_QUOTA_LIMIT_INVALID", "AI 对话次数必须在 0 到 1000000 之间,0 表示不限次数")); if (!AiChatQuotaService.TryParsePeriod(req.Period, out var period)) return BadRequest(new ApiError( "AI_QUOTA_PERIOD_INVALID", "周期仅支持 day、week 或 month")); var user = await db.Users.FindAsync(id); if (user is null) return NotFound(); var periodChanged = user.AiChatPeriod != period; user.AiChatLimit = req.Limit; user.AiChatPeriod = period; if (periodChanged || req.ResetUsage) { var (windowStart, _) = AiChatQuotaService.CurrentWindow(period); user.AiChatWindowStartedAt = windowStart; user.AiChatUsed = 0; } await db.SaveChangesAsync(); var quota = AiChatQuotaService.GetStatus(user); return Ok(new { user.Id, aiChatQuota = new { quota.Limit, quota.Used, quota.Remaining, period = AiChatQuotaService.PeriodKey(quota.Period), quota.ResetAt, }, }); } [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.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) || key.Contains("secret", StringComparison.OrdinalIgnoreCase) || key.Contains("password", 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 UpdateLlmSettingsRequest( string Protocol, string BaseUrl, string Model, int MaxTokens, double Temperature); public record UpdateLlmApiKeyRequest(string ApiKey);