using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using D3ROVoice.Api.Data; using D3ROVoice.Api.Dtos; using D3ROVoice.Api.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace D3ROVoice.Api.Controllers; [ApiController] [Route("api/[controller]")] [Authorize(Policy = "ManagerOrAbove")] public class AdminController : ControllerBase { private readonly AppDbContext _db; private readonly ISttProxyService _sttService; private readonly IAdminOperationService _adminOperations; private static readonly DateTime _serverStartTime = DateTime.UtcNow; public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations) { _db = db; _sttService = sttService; _adminOperations = adminOperations; } [HttpGet("stats")] public async Task GetStats() { var totalUsers = await _db.Users.CountAsync(); var today = DateTime.UtcNow.Date; var activeUsersToday = await _db.Users.CountAsync(u => u.LastLoginAt >= today); var totalRequests = await _db.UsageLogs.CountAsync() + await _db.SttUsageLogs.CountAsync(); var totalLlmCost = await _db.UsageLogs.SumAsync(u => (decimal?)u.CalculatedCost) ?? 0m; var totalSttCost = await _db.SttUsageLogs.SumAsync(u => (decimal?)u.CalculatedCost) ?? 0m; var totalCost = totalLlmCost + totalSttCost; var errorCount = await _db.ErrorLogs.CountAsync(); var recentErrors = await _db.ErrorLogs .OrderByDescending(e => e.CreatedAt) .Take(10) .Select(e => new ServerErrorLogDto(e.Id, e.ErrorType, e.Message, e.Endpoint, e.CreatedAt)) .ToListAsync(); var uptime = (DateTime.UtcNow - _serverStartTime).TotalSeconds; var stats = new ServerStatsDto( totalUsers, activeUsersToday, totalRequests, totalCost, uptime, errorCount, recentErrors ); return Ok(stats); } [HttpGet("users")] public async Task GetUsers() { var users = await _db.Users .OrderByDescending(u => u.CreatedAt) .Select(u => new UserInfoDto(u.Id, u.Email, u.Role, u.CreatedAt, u.LastLoginAt, u.IsActive)) .ToListAsync(); return Ok(users); } // ── LLM Model Endpoints ─────────────────────────────────────────────── [HttpGet("endpoints")] public async Task GetEndpoints() { var endpoints = await _db.ModelEndpoints .AsNoTracking() .OrderBy(m => m.Id) .Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, ApiKey = endpoint.ApiKey == "" ? "" : "••••••••", endpoint.CostPer1kPromptTokens, endpoint.CostPer1kCompletionTokens, endpoint.IsActive, endpoint.CreatedAt }) .ToListAsync(); return Ok(endpoints); } [HttpPost("endpoints")] [Authorize(Policy = "AdminOrAbove")] public async Task CreateEndpoint([FromBody] CreateModelEndpointDto dto) { if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0) { return BadRequest(new { message = "모델 식별자, 공급자, 유효한 HTTP(S) URL과 0 이상의 비용이 필요합니다." }); } return await ExecuteAdminMutationAsync( "model_endpoint.create", dto, "model_endpoint", _ => dto.ModelId.Trim(), dto.Memo, () => Task.FromResult(null), async () => { if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim())) throw new AdminOperationException("model_id_already_exists"); var endpoint = new ServiceModelEndpoint { ModelId = dto.ModelId.Trim(), ModelName = dto.ModelName.Trim(), Provider = dto.Provider.Trim(), EndpointUrl = dto.EndpointUrl.Trim(), ApiKey = dto.ApiKey?.Trim() ?? "", CostPer1kPromptTokens = dto.CostPer1kPromptTokens, CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens, IsActive = true, CreatedAt = DateTime.UtcNow }; _db.ModelEndpoints.Add(endpoint); await _db.SaveChangesAsync(); return ToModelEndpointDto(endpoint); }); } [HttpPut("endpoints/{id}")] [Authorize(Policy = "AdminOrAbove")] public async Task UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto) { if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0) return BadRequest(new { message = "유효한 모델 엔드포인트 값이 필요합니다." }); return await ExecuteAdminMutationAsync( "model_endpoint.update", new { id, dto }, "model_endpoint", _ => id.ToString(), dto.Memo, async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) .Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive }) .SingleOrDefaultAsync(), async () => { var endpoint = await _db.ModelEndpoints.FindAsync(id) ?? throw new AdminOperationException("model_endpoint_not_found"); endpoint.ModelName = dto.ModelName.Trim(); endpoint.Provider = dto.Provider.Trim(); endpoint.EndpointUrl = dto.EndpointUrl.Trim(); if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim(); endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens; endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens; endpoint.IsActive = dto.IsActive; await _db.SaveChangesAsync(); return ToModelEndpointDto(endpoint); }); } [HttpDelete("endpoints/{id}")] [Authorize(Policy = "AdminOrAbove")] public async Task DeleteEndpoint(int id, [FromBody] AdminActionDto dto) { if (id <= 0) return BadRequest(); return await ExecuteAdminMutationAsync( "model_endpoint.delete", new { id, dto.Memo }, "model_endpoint", _ => id.ToString(), dto.Memo, async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) .Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive }) .SingleOrDefaultAsync(), async () => { var endpoint = await _db.ModelEndpoints.FindAsync(id) ?? throw new AdminOperationException("model_endpoint_not_found"); _db.ModelEndpoints.Remove(endpoint); await _db.SaveChangesAsync(); return new { message = "삭제되었습니다.", id }; }); } // ── STT / Transcription Provider Endpoints ──────────────────────────── [HttpGet("stt-endpoints")] public async Task GetSttEndpoints() { var endpoints = await _sttService.GetAllEndpointsAsync(); return Ok(endpoints); } [HttpGet("stt-endpoints/{id}")] public async Task GetSttEndpoint(int id) { var endpoints = await _sttService.GetAllEndpointsAsync(); var endpoint = endpoints.FirstOrDefault(e => e.Id == id); if (endpoint == null) return NotFound(new { message = $"STT Endpoint {id} not found." }); return Ok(endpoint); } [HttpPost("stt-endpoints")] [Authorize(Policy = "AdminOrAbove")] public async Task CreateSttEndpoint([FromBody] CreateSttEndpointDto dto) { if (string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1) { return BadRequest(new { message = "유효한 STT 이름, HTTP(S) URL, 비용, 우선순위가 필요합니다." }); } return await ExecuteAdminMutationAsync( "stt_endpoint.create", dto, "stt_endpoint", result => ((SttProviderEndpointDto)result).Id.ToString(), dto.Memo, () => Task.FromResult(null), async () => await _sttService.CreateEndpointAsync(dto)); } [HttpPut("stt-endpoints/{id}")] [Authorize(Policy = "AdminOrAbove")] public async Task UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto) { if (id <= 0 || string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1) return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." }); return await ExecuteAdminMutationAsync( "stt_endpoint.update", new { id, dto }, "stt_endpoint", _ => id.ToString(), dto.Memo, async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) .Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority }) .SingleOrDefaultAsync(), async () => { try { return await _sttService.UpdateEndpointAsync(id, dto); } catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); } }); } [HttpDelete("stt-endpoints/{id}")] [Authorize(Policy = "AdminOrAbove")] public async Task DeleteSttEndpoint(int id, [FromBody] AdminActionDto dto) { if (id <= 0) return BadRequest(); return await ExecuteAdminMutationAsync( "stt_endpoint.delete", new { id, dto.Memo }, "stt_endpoint", _ => id.ToString(), dto.Memo, async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id) .Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority }) .SingleOrDefaultAsync(), async () => { if (!await _sttService.DeleteEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found"); return new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다.", id }; }); } [HttpPost("stt-endpoints/{id}/set-default")] [Authorize(Policy = "AdminOrAbove")] public async Task SetDefaultSttEndpoint(int id, [FromBody] AdminActionDto dto) { if (id <= 0) return BadRequest(); return await ExecuteAdminMutationAsync( "stt_endpoint.set_default", new { id, dto.Memo }, "stt_endpoint", _ => id.ToString(), dto.Memo, async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.IsDefault) .Select(endpoint => new { endpoint.Id, endpoint.Name }).ToListAsync(), async () => { if (!await _sttService.SetDefaultEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found"); return new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." }; }); } [HttpPost("stt-endpoints/{id}/test")] public async Task TestSttEndpoint(int id) { var result = await _sttService.TestEndpointAsync(id); return Ok(result); } [HttpPost("stt-endpoints/test-direct")] [Authorize(Policy = "AdminOrAbove")] public async Task TestSttDirect([FromBody] DirectSttTestDto dto) { if (!IsValidEndpointUrl(dto.EndpointUrl) || !Uri.TryCreate(dto.EndpointUrl, UriKind.Absolute, out var endpointUri)) { return BadRequest(new { message = "유효한 HTTP(S) Endpoint URL이 필요합니다." }); } var result = await _sttService.TestEndpointAsync(0, dto.ApiKey, endpointUri.AbsoluteUri); return Ok(result); } [HttpGet("stt-usage")] public async Task GetSttUsageReport() { var report = await _sttService.GetUsageReportAsync(); return Ok(report); } // ── LLM Usage Report ────────────────────────────────────────────────── [HttpGet("usage")] public async Task GetUsageReport() { var logs = await _db.UsageLogs.AsNoTracking().ToListAsync(); var totalRequests = logs.Count; var totalPromptTokens = logs.Sum(l => l.PromptTokens); var totalCompletionTokens = logs.Sum(l => l.CompletionTokens); var totalCost = logs.Sum(l => l.CalculatedCost); var userSummaries = logs .GroupBy(l => new { l.UserId, l.UserEmail }) .Select(g => new UserUsageSummaryDto( g.Key.UserId, g.Key.UserEmail, g.Count(), g.Sum(x => x.TotalTokens), g.Sum(x => x.CalculatedCost) )) .OrderByDescending(u => u.TotalCost) .ToList(); var modelSummaries = logs .GroupBy(l => l.ModelId) .Select(g => new ModelUsageSummaryDto( g.Key, g.Key, g.Count(), g.Sum(x => x.TotalTokens), g.Sum(x => x.CalculatedCost) )) .OrderByDescending(m => m.TotalCost) .ToList(); var report = new UsageReportDto( totalRequests, totalPromptTokens, totalCompletionTokens, totalCost, userSummaries, modelSummaries ); return Ok(report); } // 오프라인 Ed25519 라이선스 발급 감사. admin 콘솔이 서명 후 이 엔드포인트로 기록만 남긴다. [HttpPost("license-audit")] [Authorize(Policy = "SuperAdminOnly")] [RequestSizeLimit(8 * 1024)] public async Task RecordLicenseAudit([FromBody] LicenseAuditDto dto) { var actorEmail = User.FindFirstValue(ClaimTypes.Email)?.Trim().ToLowerInvariant() ?? string.Empty; if (actorEmail.Length is < 3 or > 150) return Unauthorized(new { error = "invalid_actor" }); var licenseId = dto.LicenseId?.Trim() ?? string.Empty; var customerEmail = dto.CustomerEmail?.Trim().ToLowerInvariant() ?? string.Empty; var tier = dto.Tier?.Trim() ?? string.Empty; var validity = dto.Validity?.Trim() ?? string.Empty; if (licenseId.Length is < 3 or > 200) return BadRequest(new { error = "invalid_license_id" }); if (customerEmail.Length is < 3 or > 150 || !customerEmail.Contains('@')) return BadRequest(new { error = "invalid_customer_email" }); if (tier is not ("pro" or "pro_plus" or "team" or "enterprise")) return BadRequest(new { error = "invalid_tier" }); if (validity is not ("30d" or "365d" or "lifetime")) return BadRequest(new { error = "invalid_validity" }); var afterJson = JsonSerializer.Serialize(new { licenseId, customerEmail, tier, validity, expiresAt = dto.ExpiresAt }); _db.AdminAuditEntries.Add(new AdminAuditEntry { ActorEmail = actorEmail, Action = "license.issue", TargetType = "license", TargetId = licenseId, AfterJson = afterJson, Memo = $"Issued {tier} license ({validity}) for {customerEmail}", IdempotencyKey = Guid.NewGuid().ToString("D"), CreatedAt = DateTime.UtcNow }); await _db.SaveChangesAsync(); return Ok(new { success = true }); } private static object ToModelEndpointDto(ServiceModelEndpoint endpoint) => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, ApiKey = string.IsNullOrWhiteSpace(endpoint.ApiKey) ? string.Empty : "••••••••", endpoint.CostPer1kPromptTokens, endpoint.CostPer1kCompletionTokens, endpoint.IsActive, endpoint.CreatedAt }; private static bool IsValidEndpointUrl(string? value) { if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo)) return false; if (uri.Scheme == Uri.UriSchemeHttp) return uri.IsLoopback; return uri.Scheme == Uri.UriSchemeHttps; } private async Task ExecuteAdminMutationAsync( string operation, object request, string targetType, Func targetId, string? memo, Func> readBefore, Func> mutate) { var actorEmail = User.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrWhiteSpace(actorEmail)) return Unauthorized(new { message = "관리자 이메일 claim이 필요합니다." }); var idempotencyKey = Request.Headers["Idempotency-Key"].ToString(); try { var result = await _adminOperations.ExecuteAsync( actorEmail, operation, idempotencyKey, request, targetType, targetId, memo ?? string.Empty, readBefore, mutate); return Ok(result); } catch (AdminOperationException ex) when (ex.Message == "idempotency_key_reused_with_different_request") { return Conflict(new { message = ex.Message }); } catch (AdminOperationException ex) when (ex.Message.EndsWith("_not_found", StringComparison.Ordinal)) { return NotFound(new { message = ex.Message }); } catch (AdminOperationException ex) { return BadRequest(new { message = ex.Message }); } catch (DbUpdateException) { return Conflict(new { message = "admin_operation_conflict" }); } } }