feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using D3ROVoice.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace D3ROVoice.Api.Services;
public sealed class AdminOperationException : Exception
{
public AdminOperationException(string message) : base(message) { }
}
public interface IAdminOperationService
{
Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate);
}
public sealed class AdminOperationService : IAdminOperationService
{
private readonly AppDbContext _db;
public AdminOperationService(AppDbContext db)
{
_db = db;
}
public async Task<JsonElement> ExecuteAsync(
string actorEmail,
string operation,
string idempotencyKey,
object request,
string targetType,
Func<object, string> targetId,
string memo,
Func<Task<object?>> readBefore,
Func<Task<object>> mutate)
{
actorEmail = actorEmail.Trim().ToLowerInvariant();
memo = memo.Trim();
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
var requestJson = JsonSerializer.Serialize(request);
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
if (existing != null)
{
if (existing.Operation != operation || existing.RequestHash != requestHash)
throw new AdminOperationException("idempotency_key_reused_with_different_request");
using var replay = JsonDocument.Parse(existing.ResponseJson);
return replay.RootElement.Clone();
}
var before = await readBefore();
var result = await mutate();
var responseJson = JsonSerializer.Serialize(result);
var resolvedTargetId = targetId(result);
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
throw new AdminOperationException("invalid_audit_target");
_db.AdminOperationRequests.Add(new AdminOperationRequest
{
ActorEmail = actorEmail,
IdempotencyKey = idempotencyKey,
Operation = operation,
RequestHash = requestHash,
ResponseJson = responseJson,
CreatedAt = DateTime.UtcNow
});
_db.AdminAuditEntries.Add(new AdminAuditEntry
{
ActorEmail = actorEmail,
Action = operation,
TargetType = targetType,
TargetId = resolvedTargetId,
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
AfterJson = responseJson,
Memo = memo,
IdempotencyKey = idempotencyKey,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await transaction.CommitAsync();
using var response = JsonDocument.Parse(responseJson);
return response.RootElement.Clone();
}
}

View file

@ -1,13 +1,13 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
namespace D3ROVoice.Api.Services;
@ -21,43 +21,81 @@ public interface IAuthService
public class AuthService : IAuthService
{
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly PasswordHasher<User> _passwordHasher = new();
private readonly string _dummyPasswordHash;
public AuthService(AppDbContext db, IConfiguration config)
{
_db = db;
_config = config;
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
}
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
if (existing != null)
if (dto.Password.Length < 12 || dto.Password.Length > 256)
{
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
}
var isFirstUser = !await _db.Users.AnyAsync();
var user = new User
await BootstrapLock.WaitAsync();
try
{
Email = dto.Email.Trim().ToLower(),
PasswordHash = HashPassword(dto.Password),
Role = isFirstUser ? "Admin" : "User",
CreatedAt = DateTime.UtcNow,
IsActive = true
};
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (existing != null)
{
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
}
_db.Users.Add(user);
await _db.SaveChangesAsync();
if (await _db.Users.AnyAsync(u => u.IsActive))
{
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
}
return GenerateToken(user);
var user = new User
{
Email = normalizedEmail,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true
};
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync();
return GenerateToken(user);
}
finally
{
BootstrapLock.Release();
}
}
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (user == null)
{
_passwordHasher.VerifyHashedPassword(new User(), _dummyPasswordHash, dto.Password);
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
PasswordVerificationResult verification;
try
{
verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, dto.Password);
}
catch (FormatException)
{
verification = PasswordVerificationResult.Failed;
}
if (verification == PasswordVerificationResult.Failed)
{
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
@ -68,6 +106,10 @@ public class AuthService : IAuthService
}
user.LastLoginAt = DateTime.UtcNow;
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
}
await _db.SaveChangesAsync();
return GenerateToken(user);
@ -82,7 +124,21 @@ public class AuthService : IAuthService
private AuthResponseDto GenerateToken(User user)
{
var secretKey = _config["Jwt:SecretKey"] ?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
var secretKey = _config["JWT_SECRET"];
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
{
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
}
var issuer = _config["JWT_ISSUER"];
if (string.IsNullOrWhiteSpace(issuer))
{
throw new InvalidOperationException("JWT_ISSUER is required.");
}
var audience = _config["JWT_AUDIENCE"];
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("JWT_AUDIENCE is required.");
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@ -93,11 +149,11 @@ public class AuthService : IAuthService
new Claim(ClaimTypes.Role, user.Role)
};
var expiresAt = DateTime.UtcNow.AddDays(30);
var expiresAt = DateTime.UtcNow.AddHours(8);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"] ?? "D3ROVoiceApi",
audience: _config["Jwt:Audience"] ?? "D3ROVoiceClient",
issuer: issuer,
audience: audience,
claims: claims,
expires: expiresAt,
signingCredentials: creds
@ -107,15 +163,4 @@ public class AuthService : IAuthService
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
}
public static string HashPassword(string password)
{
using var sha256 = SHA256.Create();
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "D3RO_SALT_2026"));
return Convert.ToBase64String(bytes);
}
private static bool VerifyPassword(string password, string hash)
{
return HashPassword(password) == hash;
}
}

View file

@ -23,7 +23,8 @@ public interface ISttProxyService
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null
string? fileName = null,
bool recordUsage = true
);
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
@ -35,6 +36,17 @@ public interface ISttProxyService
Task<SttUsageReportDto> GetUsageReportAsync();
}
public sealed class SttProviderUnavailableException : Exception
{
public bool ProviderAttempted { get; }
public SttProviderUnavailableException(bool providerAttempted)
: base("STT service is temporarily unavailable.")
{
ProviderAttempted = providerAttempted;
}
}
public class SttProxyService : ISttProxyService
{
private readonly AppDbContext _db;
@ -54,7 +66,8 @@ public class SttProxyService : ISttProxyService
SttTranscribeRequest request,
byte[]? audioBytes = null,
string? contentType = null,
string? fileName = null
string? fileName = null,
bool recordUsage = true
)
{
// 1. Resolve Audio Bytes
@ -94,23 +107,12 @@ public class SttProxyService : ISttProxyService
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
if (candidates.Count == 0)
{
// Seed a dynamic fallback endpoint in memory
candidates.Add(new SttProviderEndpoint
{
Id = 0,
Name = "Default Groq Whisper Fallback",
ProviderType = "groq",
EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions",
ModelId = "whisper-large-v3-turbo",
Method = "multipart",
Language = request.Language ?? "ko",
CostPerMinute = 0.000500m,
IsActive = true
});
throw new SttProviderUnavailableException(providerAttempted: false);
}
var totalSw = Stopwatch.StartNew();
Exception? lastException = null;
var providerAttempted = false;
// 3. Failover Execution Chain
foreach (var endpoint in candidates)
@ -118,6 +120,14 @@ public class SttProxyService : ISttProxyService
var epSw = Stopwatch.StartNew();
try
{
if (!HasRequiredProviderConfiguration(endpoint))
{
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
endpoint.ProviderType);
continue;
}
providerAttempted = true;
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
@ -136,29 +146,33 @@ public class SttProxyService : ISttProxyService
var durationMinutes = (decimal)(finalDuration / 60.0);
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
// Record Usage Log
try
// Supabase stt-proxy owns user quota and usage for internal
// provider calls. Direct legacy callers may still record here.
if (recordUsage)
{
var usageLog = new SttUsageLog
try
{
UserId = userId,
UserEmail = userEmail,
EndpointId = endpoint.Id,
Provider = endpoint.ProviderType,
ModelId = endpoint.ModelId,
AudioDurationSeconds = Math.Round(finalDuration, 2),
CalculatedCost = cost,
LatencyMs = (int)epSw.ElapsedMilliseconds,
StatusCode = 200,
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
CreatedAt = DateTime.UtcNow
};
_db.SttUsageLogs.Add(usageLog);
await _db.SaveChangesAsync();
}
catch (Exception dbEx)
{
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
var usageLog = new SttUsageLog
{
UserId = userId,
UserEmail = userEmail,
EndpointId = endpoint.Id,
Provider = endpoint.ProviderType,
ModelId = endpoint.ModelId,
AudioDurationSeconds = Math.Round(finalDuration, 2),
CalculatedCost = cost,
LatencyMs = (int)epSw.ElapsedMilliseconds,
StatusCode = 200,
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
CreatedAt = DateTime.UtcNow
};
_db.SttUsageLogs.Add(usageLog);
await _db.SaveChangesAsync();
}
catch (Exception dbEx)
{
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
}
}
return new SttTranscribeResponse(
@ -176,18 +190,19 @@ public class SttProxyService : ISttProxyService
{
epSw.Stop();
lastException = ex;
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
var failureType = GetSafeFailureType(ex);
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
// Record error to ErrorLogs
try
{
_db.ErrorLogs.Add(new ServerErrorLog
{
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
StackTrace = ex.StackTrace,
Endpoint = endpoint.EndpointUrl,
ErrorType = "SttProviderError",
Message = $"STT provider request failed ({failureType}).",
StackTrace = null,
Endpoint = null,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
@ -200,19 +215,9 @@ public class SttProxyService : ISttProxyService
}
totalSw.Stop();
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
return new SttTranscribeResponse(
Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)",
Confidence: 0.95,
Language: request.Language ?? "ko",
DurationSeconds: Math.Round(durationSeconds, 2),
Provider: "fallback-local",
ModelId: "whisper-local",
LatencyMs: totalSw.ElapsedMilliseconds,
Cost: 0m
);
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
throw new SttProviderUnavailableException(providerAttempted);
}
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
@ -229,15 +234,9 @@ public class SttProxyService : ISttProxyService
var providerType = endpoint.ProviderType.ToLowerInvariant();
var apiKey = endpoint.ApiKey?.Trim() ?? "";
// If no API key configured or local mock
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
{
return (
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
0.99,
request.Language ?? endpoint.Language ?? "ko",
EstimateAudioDuration(audioBytes, contentType)
);
throw new InvalidOperationException("STT provider credentials are not configured.");
}
switch (providerType)
@ -325,17 +324,17 @@ public class SttProxyService : ISttProxyService
if (!httpResponse.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT Upstream {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("text", out var textProp))
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
{
text = textProp.GetString() ?? "";
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProp.GetString() ?? "";
var duration = 0.0;
if (root.TryGetProperty("duration", out var durProp))
@ -385,28 +384,29 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Deepgram returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var transcript = "";
string transcript;
var confidence = 0.95;
var duration = 0.0;
if (root.TryGetProperty("results", out var results) &&
results.TryGetProperty("channels", out var channels) &&
channels.GetArrayLength() > 0)
if (!root.TryGetProperty("results", out var results) ||
!results.TryGetProperty("channels", out var channels) ||
channels.GetArrayLength() == 0 ||
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
alternatives.GetArrayLength() == 0 ||
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
transcriptProperty.ValueKind != JsonValueKind.String)
{
var ch0 = channels[0];
if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
{
var alt0 = alts[0];
if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? "";
if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.GetDouble();
}
throw new JsonException("STT upstream response did not contain a transcript.");
}
transcript = transcriptProperty.GetString() ?? "";
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
confidence = confidenceProperty.GetDouble();
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
{
@ -431,9 +431,6 @@ public class SttProxyService : ISttProxyService
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
{
var apiKey = endpoint.ApiKey;
var url = endpoint.EndpointUrl.Contains("?")
? $"{endpoint.EndpointUrl}&key={apiKey}"
: $"{endpoint.EndpointUrl}?key={apiKey}";
var geminiPayload = new
{
@ -460,32 +457,33 @@ public class SttProxyService : ISttProxyService
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
if (!root.TryGetProperty("candidates", out var cands) ||
cands.GetArrayLength() == 0 ||
!cands[0].TryGetProperty("content", out var content) ||
!content.TryGetProperty("parts", out var parts) ||
parts.GetArrayLength() == 0 ||
!parts[0].TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
var cand0 = cands[0];
if (cand0.TryGetProperty("content", out var content) &&
content.TryGetProperty("parts", out var parts) &&
parts.GetArrayLength() > 0)
{
text = parts[0].GetProperty("text").GetString() ?? "";
}
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
}
@ -493,9 +491,6 @@ public class SttProxyService : ISttProxyService
{
// Google Cloud Speech-to-Text v1
var apiKey = endpoint.ApiKey;
var url = endpoint.EndpointUrl.Contains("?")
? $"{endpoint.EndpointUrl}&key={apiKey}"
: $"{endpoint.EndpointUrl}?key={apiKey}";
var gcpPayload = new
{
@ -512,17 +507,18 @@ public class SttProxyService : ISttProxyService
}
};
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
{
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
};
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
var response = await client.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
@ -530,7 +526,12 @@ public class SttProxyService : ISttProxyService
var transcript = "";
var confidence = 0.95;
if (root.TryGetProperty("results", out var results) && results.GetArrayLength() > 0)
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
{
throw new JsonException("STT upstream response did not contain transcription results.");
}
if (results.GetArrayLength() > 0)
{
var sb = new StringBuilder();
foreach (var res in results.EnumerateArray())
@ -568,7 +569,7 @@ public class SttProxyService : ISttProxyService
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
if (!uploadResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"AssemblyAI upload failed: {uploadBody}");
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
}
using var uploadDoc = JsonDocument.Parse(uploadBody);
@ -594,7 +595,7 @@ public class SttProxyService : ISttProxyService
var transBody = await transResp.Content.ReadAsStringAsync();
if (!transResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"AssemblyAI transcript job failed: {transBody}");
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
}
using var transDoc = JsonDocument.Parse(transBody);
@ -610,12 +611,22 @@ public class SttProxyService : ISttProxyService
var pollResp = await client.SendAsync(pollReq);
var pollBody = await pollResp.Content.ReadAsStringAsync();
if (!pollResp.IsSuccessStatusCode)
{
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
}
using var pollDoc = JsonDocument.Parse(pollBody);
var status = pollDoc.RootElement.GetProperty("status").GetString();
if (status == "completed")
{
var text = pollDoc.RootElement.GetProperty("text").GetString() ?? "";
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
textProperty.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = textProperty.GetString() ?? "";
var confidence = 0.95;
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
var duration = 0.0;
@ -624,8 +635,7 @@ public class SttProxyService : ISttProxyService
}
if (status == "error")
{
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
throw new HttpRequestException($"AssemblyAI processing error: {err}");
throw new HttpRequestException("STT upstream processing failed.");
}
}
@ -663,24 +673,32 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
string text;
var confidence = 0.95;
if (root.TryGetProperty("DisplayText", out var dt))
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
{
text = dt.GetString() ?? "";
}
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
{
var best = nbest[0];
if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? "";
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
text = display.GetString() ?? "";
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
}
else
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
}
@ -717,14 +735,17 @@ public class SttProxyService : ISttProxyService
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}");
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var text = "";
if (root.TryGetProperty("text", out var tProp)) text = tProp.GetString() ?? "";
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
{
throw new JsonException("STT upstream response did not contain a transcript.");
}
var text = tProp.GetString() ?? "";
var duration = EstimateAudioDuration(audioBytes, contentType);
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
@ -789,7 +810,7 @@ public class SttProxyService : ISttProxyService
sw.Stop();
return new SttTestResultDto(
Success: false,
Message: $"연결 실패: {ex.Message}",
Message: $"연결 실패: {GetSafeFailureType(ex)}",
LatencyMs: sw.ElapsedMilliseconds,
TranscriptPreview: null,
Provider: endpoint.ProviderType,
@ -980,6 +1001,32 @@ public class SttProxyService : ISttProxyService
.ToListAsync();
}
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
}
private static bool RequiresApiKey(string providerType)
{
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
}
private static string GetSafeFailureType(Exception? exception)
{
return exception switch
{
TimeoutException or TaskCanceledException => "timeout",
HttpRequestException => "upstream_http_error",
JsonException => "invalid_upstream_response",
InvalidOperationException => "provider_not_configured",
null => "not_configured",
_ => "provider_error"
};
}
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
{
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;