465 lines
25 KiB
C#
465 lines
25 KiB
C#
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<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) }); }
|
|
|
|
[HttpGet("configs")]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
|
|
[HttpPost("personas")] public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ListAvatars() => Ok(await db.AiAvatars.OrderBy(a => a.Key).ToListAsync());
|
|
[HttpPost("avatars")] public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ListStickers() => Ok(await db.Stickers.OrderBy(s => s.GroupKey).ThenBy(s => s.Key).ToListAsync());
|
|
[HttpPost("stickers")] public async Task<IActionResult> 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<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(); }
|
|
|
|
[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 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")]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<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.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<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 UpdateLlmSettingsRequest(
|
|
string Protocol,
|
|
string BaseUrl,
|
|
string Model,
|
|
int MaxTokens,
|
|
double Temperature);
|
|
public record UpdateLlmApiKeyRequest(string ApiKey);
|
|
|