Add full profiles and latency tests
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
@@ -15,12 +16,15 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ITokenService _tokens;
|
||||
private readonly IAppEmailSender _email;
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email)
|
||||
private readonly IGoogleTokenValidator _googleTokens;
|
||||
|
||||
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_users = users;
|
||||
_tokens = tokens;
|
||||
_email = email;
|
||||
_googleTokens = googleTokens;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
@@ -43,6 +47,19 @@ public sealed class AuthController : ControllerBase
|
||||
public sealed record LoginRequest(string Email, string Password);
|
||||
public sealed record RegisterRequest(string Email, string Password);
|
||||
public sealed record AuthResult(string AccessToken, string TokenType);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MeResult(
|
||||
string Provider,
|
||||
string? Id,
|
||||
string? Email,
|
||||
string? UserName,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? DisplayName,
|
||||
IList<string> Roles,
|
||||
GoogleLinkDto? GoogleLink);
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName);
|
||||
public sealed record GoogleTokenRequest(string Token);
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
@@ -91,6 +108,44 @@ public sealed class AuthController : ControllerBase
|
||||
return Ok(new AuthResult(token, "Bearer"));
|
||||
}
|
||||
|
||||
[HttpPost("google/exchange")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = (request.Token ?? "").Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
|
||||
GoogleTokenPrincipal google;
|
||||
try
|
||||
{
|
||||
google = await _googleTokens.ValidateAsync(token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Unauthorized(ex.Message);
|
||||
}
|
||||
|
||||
var user = await _users.Users.FirstOrDefaultAsync(
|
||||
x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email),
|
||||
cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized("This Google account is not linked to a Job Tracker user yet.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal))
|
||||
{
|
||||
user.GoogleSubject = google.Subject;
|
||||
user.GoogleEmail = google.Email;
|
||||
user.GoogleLinkedAt ??= DateTimeOffset.UtcNow;
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
|
||||
var appToken = await _tokens.CreateAccessTokenAsync(user, cancellationToken);
|
||||
return Ok(new AuthResult(appToken, "Bearer"));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
@@ -99,48 +154,47 @@ public sealed class AuthController : ControllerBase
|
||||
if (u is not null)
|
||||
{
|
||||
var roles = await _users.GetRolesAsync(u);
|
||||
return Ok(new
|
||||
{
|
||||
provider = "local",
|
||||
id = u.Id,
|
||||
email = u.Email,
|
||||
userName = u.UserName,
|
||||
roles
|
||||
});
|
||||
return Ok(ToMeResult(u, roles));
|
||||
}
|
||||
|
||||
// Google direct bearer tokens (or any other external JWT) won’t map to an Identity user.
|
||||
var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");
|
||||
var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
var iss = User.FindFirstValue("iss") ?? "";
|
||||
var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external";
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
provider,
|
||||
id = sub,
|
||||
email,
|
||||
roles = Array.Empty<string>()
|
||||
});
|
||||
return Ok(new MeResult(
|
||||
Provider: provider,
|
||||
Id: sub,
|
||||
Email: email,
|
||||
UserName: null,
|
||||
FirstName: User.FindFirstValue("given_name"),
|
||||
LastName: User.FindFirstValue("family_name"),
|
||||
DisplayName: User.FindFirstValue("name"),
|
||||
Roles: Array.Empty<string>(),
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
|
||||
}
|
||||
public sealed record UpdateProfileRequest(string? Email, string? UserName);
|
||||
|
||||
[HttpPut("profile")]
|
||||
[Authorize]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
|
||||
{
|
||||
var u = await _users.GetUserAsync(User);
|
||||
if (u is null)
|
||||
{
|
||||
// Google bearer tokens are accepted, but they don't map to Identity users in this build.
|
||||
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
|
||||
}
|
||||
|
||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||
var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim();
|
||||
var email = TrimOrNull(request.Email);
|
||||
var userName = TrimOrNull(request.UserName);
|
||||
var firstName = TrimOrNull(request.FirstName);
|
||||
var lastName = TrimOrNull(request.LastName);
|
||||
var displayName = TrimOrNull(request.DisplayName);
|
||||
|
||||
if (email is not null) u.Email = email;
|
||||
if (userName is not null) u.UserName = userName;
|
||||
u.FirstName = firstName;
|
||||
u.LastName = lastName;
|
||||
u.DisplayName = displayName;
|
||||
|
||||
var res = await _users.UpdateAsync(u);
|
||||
if (!res.Succeeded)
|
||||
@@ -149,10 +203,80 @@ public sealed class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("google/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var token = (request.Token ?? "").Trim();
|
||||
if (token.Length == 0) return BadRequest("Google token is required.");
|
||||
|
||||
GoogleTokenPrincipal google;
|
||||
try
|
||||
{
|
||||
google = await _googleTokens.ValidateAsync(token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
var conflict = await _users.Users
|
||||
.Where(x => x.Id != user.Id)
|
||||
.FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken);
|
||||
if (conflict is not null)
|
||||
{
|
||||
return Conflict("That Google account is already linked to another Job Tracker user.");
|
||||
}
|
||||
|
||||
user.GoogleSubject = google.Subject;
|
||||
user.GoogleEmail = google.Email;
|
||||
user.GoogleLinkedAt = DateTimeOffset.UtcNow;
|
||||
user.DisplayName ??= TrimOrNull(google.Name);
|
||||
user.FirstName ??= TrimOrNull(google.GivenName);
|
||||
user.LastName ??= TrimOrNull(google.FamilyName);
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt));
|
||||
}
|
||||
|
||||
[HttpDelete("google/link")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UnlinkGoogle()
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.GoogleSubject = null;
|
||||
user.GoogleEmail = null;
|
||||
user.GoogleLinkedAt = null;
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
|
||||
[HttpPost("change-password")]
|
||||
[Authorize]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var u = await _users.GetUserAsync(User);
|
||||
@@ -170,6 +294,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record RequestPasswordResetRequest(string Email);
|
||||
|
||||
[HttpPost("request-password-reset")]
|
||||
@@ -182,7 +307,6 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _users.FindByEmailAsync(email);
|
||||
if (user is null || string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
// Avoid user enumeration.
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -229,5 +353,28 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
|
||||
{
|
||||
return new MeResult(
|
||||
Provider: "local",
|
||||
Id: user.Id,
|
||||
Email: user.Email,
|
||||
UserName: user.UserName,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
DisplayName: user.DisplayName,
|
||||
Roles: roles,
|
||||
GoogleLink: new GoogleLinkDto(
|
||||
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
|
||||
Email: user.GoogleEmail,
|
||||
LinkedAt: user.GoogleLinkedAt));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,17 @@ public sealed class UsersController : ControllerBase
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
public sealed record UserDto(string Id, string? Email, string? UserName, bool EmailConfirmed, List<string> Roles);
|
||||
public sealed record UserDto(
|
||||
string Id,
|
||||
string? Email,
|
||||
string? UserName,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? DisplayName,
|
||||
bool EmailConfirmed,
|
||||
string? GoogleEmail,
|
||||
DateTimeOffset? GoogleLinkedAt,
|
||||
List<string> Roles);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
||||
@@ -38,13 +48,13 @@ public sealed class UsersController : ControllerBase
|
||||
foreach (var u in items)
|
||||
{
|
||||
var rs = await _users.GetRolesAsync(u);
|
||||
outList.Add(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
|
||||
outList.Add(ToDto(u, rs.ToList()));
|
||||
}
|
||||
|
||||
return Ok(outList);
|
||||
}
|
||||
|
||||
public sealed record CreateUserRequest(string Email, string Password, string[]? Roles);
|
||||
public sealed record CreateUserRequest(string Email, string Password, string? UserName, string? FirstName, string? LastName, string? DisplayName, string[]? Roles);
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken)
|
||||
@@ -58,7 +68,15 @@ public sealed class UsersController : ControllerBase
|
||||
var existing = await _users.FindByEmailAsync(email);
|
||||
if (existing is not null) return BadRequest("User already exists.");
|
||||
|
||||
var u = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var u = new ApplicationUser
|
||||
{
|
||||
UserName = string.IsNullOrWhiteSpace(request.UserName) ? email : request.UserName.Trim(),
|
||||
Email = email,
|
||||
EmailConfirmed = true,
|
||||
FirstName = TrimOrNull(request.FirstName),
|
||||
LastName = TrimOrNull(request.LastName),
|
||||
DisplayName = TrimOrNull(request.DisplayName)
|
||||
};
|
||||
var res = await _users.CreateAsync(u, password);
|
||||
if (!res.Succeeded)
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
@@ -72,7 +90,7 @@ public sealed class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
var rs = await _users.GetRolesAsync(u);
|
||||
return Ok(new UserDto(u.Id, u.Email, u.UserName, u.EmailConfirmed, rs.ToList()));
|
||||
return Ok(ToDto(u, rs.ToList()));
|
||||
}
|
||||
|
||||
public sealed record SetRolesRequest(string[] Roles);
|
||||
@@ -167,4 +185,24 @@ public sealed class UsersController : ControllerBase
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static UserDto ToDto(ApplicationUser user, List<string> roles)
|
||||
{
|
||||
return new UserDto(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.UserName,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.DisplayName,
|
||||
user.EmailConfirmed,
|
||||
user.GoogleEmail,
|
||||
user.GoogleLinkedAt,
|
||||
roles);
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ builder.Services.AddDataProtection()
|
||||
builder.Services.AddHostedService<RulesHostedService>();
|
||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
@@ -112,6 +113,7 @@ builder.Services.AddHttpClient("summarizer", client =>
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
@@ -382,7 +384,13 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" (
|
||||
"TwoFactorEnabled" INTEGER NOT NULL,
|
||||
"LockoutEnd" TEXT NULL,
|
||||
"LockoutEnabled" INTEGER NOT NULL,
|
||||
"AccessFailedCount" INTEGER NOT NULL
|
||||
"AccessFailedCount" INTEGER NOT NULL,
|
||||
"FirstName" TEXT NULL,
|
||||
"LastName" TEXT NULL,
|
||||
"DisplayName" TEXT NULL,
|
||||
"GoogleSubject" TEXT NULL,
|
||||
"GoogleEmail" TEXT NULL,
|
||||
"GoogleLinkedAt" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
@@ -448,6 +456,18 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
|
||||
}
|
||||
|
||||
EnsureIdentityTables(conn);
|
||||
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||||
|
||||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||
{
|
||||
@@ -605,3 +625,9 @@ app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record GoogleTokenPrincipal(string Subject, string? Email, string? GivenName, string? FamilyName, string? Name);
|
||||
|
||||
public interface IGoogleTokenValidator
|
||||
{
|
||||
Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class GoogleTokenValidator : IGoogleTokenValidator
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configManager;
|
||||
|
||||
public GoogleTokenValidator(IConfiguration cfg)
|
||||
{
|
||||
_cfg = cfg;
|
||||
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
"https://accounts.google.com/.well-known/openid-configuration",
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
}
|
||||
|
||||
public async Task<GoogleTokenPrincipal> ValidateAsync(string idToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var audience = (_cfg["Auth:GoogleClientId"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new InvalidOperationException("Google sign-in is not configured.");
|
||||
}
|
||||
|
||||
var config = await _configManager.GetConfigurationAsync(cancellationToken);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudience = audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = config.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2),
|
||||
}, out _);
|
||||
|
||||
var subject = principal.FindFirst("sub")?.Value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
throw new InvalidOperationException("Google token is missing a subject.");
|
||||
}
|
||||
|
||||
return new GoogleTokenPrincipal(
|
||||
Subject: subject,
|
||||
Email: principal.FindFirst("email")?.Value?.Trim(),
|
||||
GivenName: principal.FindFirst("given_name")?.Value?.Trim(),
|
||||
FamilyName: principal.FindFirst("family_name")?.Value?.Trim(),
|
||||
Name: principal.FindFirst("name")?.Value?.Trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
@@ -13,6 +16,11 @@ namespace JobTrackerApi.Services
|
||||
bool Healthy,
|
||||
string? Model,
|
||||
double? HealthLatencyMs,
|
||||
double? ProbeLatencyMs,
|
||||
DateTimeOffset? LastProbeAt,
|
||||
DateTimeOffset? LastProbeSuccessAt,
|
||||
DateTimeOffset? LastProbeFailureAt,
|
||||
int ProbeFailures,
|
||||
int Requests,
|
||||
int CacheHits,
|
||||
int CacheMisses,
|
||||
@@ -26,6 +34,7 @@ namespace JobTrackerApi.Services
|
||||
public interface ISummarizerService
|
||||
{
|
||||
Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30);
|
||||
Task RunProbeAsync(CancellationToken cancellationToken = default);
|
||||
Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -41,6 +50,11 @@ namespace JobTrackerApi.Services
|
||||
private long _totalLatencyTicks;
|
||||
private DateTimeOffset? _lastSuccessAt;
|
||||
private DateTimeOffset? _lastFailureAt;
|
||||
private double? _lastProbeLatencyMs;
|
||||
private DateTimeOffset? _lastProbeAt;
|
||||
private DateTimeOffset? _lastProbeSuccessAt;
|
||||
private DateTimeOffset? _lastProbeFailureAt;
|
||||
private int _probeFailures;
|
||||
private string? _lastError;
|
||||
|
||||
public SummarizerService(IHttpClientFactory httpFactory, IMemoryCache cache)
|
||||
@@ -111,6 +125,69 @@ namespace JobTrackerApi.Services
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunProbeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string probeText = "Summarizer latency probe for job tracker telemetry.";
|
||||
var client = _httpFactory.CreateClient("summarizer");
|
||||
var payload = JsonSerializer.Serialize(new { text = probeText, max_length = 48, min_length = 12 });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var res = await client.PostAsync("/summarize", content, cancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeAt = DateTimeOffset.UtcNow;
|
||||
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
|
||||
}
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
{
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = $"Probe returned {(int)res.StatusCode}.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = await res.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("summary", out var summaryEl) || string.IsNullOrWhiteSpace(summaryEl.GetString()))
|
||||
{
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = "Probe returned an empty summary.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeSuccessAt = DateTimeOffset.UtcNow;
|
||||
_lastError = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Interlocked.Increment(ref _probeFailures);
|
||||
lock (_metricsLock)
|
||||
{
|
||||
_lastProbeAt = DateTimeOffset.UtcNow;
|
||||
_lastProbeLatencyMs = Math.Round(sw.Elapsed.TotalMilliseconds, 1);
|
||||
_lastProbeFailureAt = DateTimeOffset.UtcNow;
|
||||
_lastError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SummarizerMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var client = _httpFactory.CreateClient("summarizer");
|
||||
@@ -154,11 +231,19 @@ namespace JobTrackerApi.Services
|
||||
|
||||
DateTimeOffset? lastSuccessAt;
|
||||
DateTimeOffset? lastFailureAt;
|
||||
double? probeLatencyMs;
|
||||
DateTimeOffset? lastProbeAt;
|
||||
DateTimeOffset? lastProbeSuccessAt;
|
||||
DateTimeOffset? lastProbeFailureAt;
|
||||
string? lastError;
|
||||
lock (_metricsLock)
|
||||
{
|
||||
lastSuccessAt = _lastSuccessAt;
|
||||
lastFailureAt = _lastFailureAt;
|
||||
probeLatencyMs = _lastProbeLatencyMs;
|
||||
lastProbeAt = _lastProbeAt;
|
||||
lastProbeSuccessAt = _lastProbeSuccessAt;
|
||||
lastProbeFailureAt = _lastProbeFailureAt;
|
||||
lastError = _lastError;
|
||||
}
|
||||
|
||||
@@ -175,6 +260,11 @@ namespace JobTrackerApi.Services
|
||||
Healthy: healthy,
|
||||
Model: model,
|
||||
HealthLatencyMs: healthLatencyMs,
|
||||
ProbeLatencyMs: probeLatencyMs,
|
||||
LastProbeAt: lastProbeAt,
|
||||
LastProbeSuccessAt: lastProbeSuccessAt,
|
||||
LastProbeFailureAt: lastProbeFailureAt,
|
||||
ProbeFailures: Volatile.Read(ref _probeFailures),
|
||||
Requests: requests,
|
||||
CacheHits: cacheHits,
|
||||
CacheMisses: cacheMisses,
|
||||
@@ -186,4 +276,55 @@ namespace JobTrackerApi.Services
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SummarizerProbeHostedService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SummarizerProbeHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public SummarizerProbeHostedService(IServiceScopeFactory scopeFactory, ILogger<SummarizerProbeHostedService> logger, IConfiguration cfg)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var enabled = _cfg.GetValue("Summarizer:ProbeEnabled", true);
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var intervalSeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeIntervalSeconds", 300), 30, 3600);
|
||||
var initialDelaySeconds = Math.Clamp(_cfg.GetValue("Summarizer:ProbeInitialDelaySeconds", 15), 0, 600);
|
||||
|
||||
if (initialDelaySeconds > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(intervalSeconds));
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
|
||||
await summarizer.RunProbeAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Summarizer latency probe failed.");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user