Table QuestionThemeMonthly (instance, mois, thème, compteur). C'est elle qui rend tenable le §8.4 des CGU : le regroupement vivait dans ThemeId, colonne de la ligne VisitorQuestion, donc la purge du 90e jour l'emportait avec la question et le client perdait tout au 91e. Elle ne porte que des compteurs — aucune donnée personnelle, ce qui est précisément ce qui l'autorise à survivre. Insights lit désormais les thèmes dans cette table, pas dans les questions de la fenêtre. Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA : des libellés régénérés à chaque passage donneraient « Horaires » en janvier et « Questions d'horaires » en février, deux lignes distinctes et une courbe qui ne veut rien dire — alors que la table existe pour porter cet historique. Le job tourne à 2 h, la purge à 3 h 30 : une question purgée avant d'avoir été classée ne compte dans aucun agrégat et rien ne peut la rattraper. Le plafond de 500 par passage ne perd rien, il retarde — les plus anciennes d'abord, un passage par jour, avertissement si le retard dépasse un passage. Les jetons ne sont pas décomptés du quota client : il n'a pas demandé ces appels. Un lot en échec n'est pas marqué « Autre » pour s'en débarrasser, ce serait une perte définitive maquillée en résultat ; et la relecture se fait par numéro, jamais par position, pour qu'une ligne manquante ne décale pas les suivantes. Instance.IsVisitorQuestionCollectionEnabled (défaut true) + garde dans Chat : le client est responsable de traitement, la collecte était inconditionnelle. Ajout d'une fabrique design-time : EF construisait tout l'hôte pour trouver le contexte, et l'hôte ouvre une connexion au démarrage — générer une migration exigeait donc une base joignable, impossible sur une machine sans Postgres ni Docker. dotnet test : 211 passés, 15 sautés, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
469 lines
20 KiB
C#
469 lines
20 KiB
C#
using FirebaseAdmin;
|
||
using Google.Apis.Auth.OAuth2;
|
||
using Hangfire;
|
||
using Hangfire.PostgreSql;
|
||
using Microsoft.Extensions.AI;
|
||
using OpenAI;
|
||
using System.ClientModel;
|
||
using Manager.Framework.Models;
|
||
using Manager.Helpers;
|
||
using Manager.Interfaces;
|
||
using Manager.Interfaces.Models;
|
||
using Manager.Services;
|
||
using ManagerService.Data;
|
||
using ManagerService.Extensions;
|
||
using ManagerService.Helpers;
|
||
using ManagerService.Security;
|
||
using ManagerService.Service;
|
||
using ManagerService.Service.Services;
|
||
using Microsoft.AspNetCore.Authentication;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Builder;
|
||
using Microsoft.AspNetCore.Diagnostics;
|
||
using Microsoft.AspNetCore.Hosting;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Http.Features;
|
||
using Microsoft.AspNetCore.HttpsPolicy;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.AspNetCore.RateLimiting;
|
||
using System.Threading.RateLimiting;
|
||
using ManagerService.Controllers;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.FileProviders;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using Mqtt.Client.AspNetCore.Settings;
|
||
using MyCore.Service.Extensions;
|
||
using Npgsql;
|
||
using NSwag;
|
||
using NSwag.Generation.AspNetCore;
|
||
using NSwag.Generation.Processors.Security;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.Json.Serialization;
|
||
using System.Threading.Tasks;
|
||
using ManagerService.Services;
|
||
using Serilog;
|
||
|
||
namespace ManagerService
|
||
{
|
||
public class Startup
|
||
{
|
||
public Startup(IConfiguration configuration)
|
||
{
|
||
Configuration = configuration;
|
||
|
||
MapConfiguration();
|
||
}
|
||
|
||
public IConfiguration Configuration { get; }
|
||
|
||
private void MapConfiguration()
|
||
{
|
||
MapBrokerHostSettings();
|
||
MapClientSettings();
|
||
}
|
||
|
||
private void MapBrokerHostSettings()
|
||
{
|
||
BrokerHostSettings brokerHostSettings = new BrokerHostSettings();
|
||
Configuration.GetSection(nameof(BrokerHostSettings)).Bind(brokerHostSettings);
|
||
AppSettingsProvider.BrokerHostSettings = brokerHostSettings;
|
||
}
|
||
|
||
private void MapClientSettings()
|
||
{
|
||
ClientSettings clientSettings = new ClientSettings();
|
||
Configuration.GetSection(nameof(ClientSettings)).Bind(clientSettings);
|
||
AppSettingsProvider.ClientSettings = clientSettings;
|
||
}
|
||
|
||
// This method gets called by the runtime. Use this method to add services to the container.
|
||
public void ConfigureServices(IServiceCollection services)
|
||
{
|
||
// Swagger
|
||
services.AddControllers()
|
||
.AddJsonOptions(options =>
|
||
{
|
||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||
});
|
||
|
||
services.AddOpenApiDocument(config =>
|
||
{
|
||
ConfigureSwagger(config);
|
||
});
|
||
|
||
services.AddCors(o => o.AddPolicy("AllowAll", builder =>
|
||
{
|
||
builder.AllowAnyOrigin()
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader();
|
||
}));
|
||
|
||
services.Configure<FormOptions>(o => {
|
||
o.ValueLengthLimit = int.MaxValue;
|
||
o.MultipartBodyLengthLimit = int.MaxValue;
|
||
o.MemoryBufferThreshold = int.MaxValue;
|
||
});
|
||
|
||
// Authentication
|
||
|
||
var tokensConfiguration = Configuration.GetSection("Tokens");
|
||
var tokenSettings = tokensConfiguration.Get<TokensSettings>();
|
||
|
||
services.Configure<TokensSettings>(tokensConfiguration);
|
||
services.Configure<StripeSettings>(Configuration.GetSection("Stripe"));
|
||
services.Configure<ResendSettings>(Configuration.GetSection("Resend"));
|
||
|
||
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
|
||
services.AddAuthorization(options =>
|
||
{
|
||
options.AddPolicy(policy.Name, policyAdmin =>
|
||
{
|
||
policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey");
|
||
foreach (var claim in policy.Claims)
|
||
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
|
||
});
|
||
});
|
||
|
||
services.AddAuthorization(options =>
|
||
options.AddPolicy(ManagerService.Service.Security.Policies.AppReadAccess, policy =>
|
||
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey")
|
||
.RequireAuthenticatedUser()));
|
||
|
||
services
|
||
.AddAuthentication(x =>
|
||
{
|
||
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
})
|
||
.AddJwtBearer(x =>
|
||
{
|
||
x.RequireHttpsMetadata = false;
|
||
x.SaveToken = true;
|
||
x.TokenValidationParameters = new TokenValidationParameters
|
||
{
|
||
ValidateIssuerSigningKey = true,
|
||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenSettings.Secret)),
|
||
ValidateIssuer = false,
|
||
ValidateAudience = false,
|
||
RequireExpirationTime = false,
|
||
ValidateLifetime = true
|
||
};
|
||
})
|
||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", _ => { });
|
||
|
||
#if RELEASE
|
||
//services.AddMqttClientHostedService();
|
||
#endif
|
||
services.AddHttpContextAccessor();
|
||
services.AddScoped(typeof(ProfileLogic));
|
||
services.AddScoped<TokensService>();
|
||
services.AddScoped<LanguageInit>();
|
||
|
||
// OLD services
|
||
services.AddScoped<UserDatabaseService>();
|
||
services.AddScoped<SectionDatabaseService>();
|
||
services.AddScoped<ConfigurationDatabaseService>();
|
||
services.AddScoped<ResourceDatabaseService>();
|
||
services.AddScoped<DeviceDatabaseService>();
|
||
services.AddScoped<InstanceDatabaseService>();
|
||
services.AddScoped<ApiKeyDatabaseService>();
|
||
services.AddScoped<IEmailService, ResendEmailService>();
|
||
services.AddScoped<StripeService>();
|
||
|
||
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
|
||
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
|
||
// Gemini : endpoint OpenAI-compatible (ci-dessous)
|
||
// Anthropic : package Anthropic.SDK → new AnthropicClient(apiKey).Messages.AsChatClient("claude-haiku-4-5-20251001")
|
||
services.AddSingleton<IChatClient>(_ =>
|
||
new OpenAIClient(
|
||
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
|
||
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
|
||
).AsChatClient("gemini-2.5-flash-lite")
|
||
.AsBuilder()
|
||
.UseFunctionInvocation()
|
||
.Build());
|
||
services.AddScoped<IAssistantService, AssistantService>();
|
||
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
|
||
services.AddScoped<IVectorStoreService, VectorStoreService>();
|
||
services.AddScoped<IIngestionService, IngestionService>();
|
||
|
||
// Push Notifications
|
||
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
|
||
if (!string.IsNullOrEmpty(firebaseCredentialsPath) && FirebaseApp.DefaultInstance == null)
|
||
{
|
||
FirebaseApp.Create(new AppOptions
|
||
{
|
||
Credential = GoogleCredential.FromFile(firebaseCredentialsPath)
|
||
});
|
||
}
|
||
services.AddSingleton<NotificationService>();
|
||
// Réutilise le credential Firebase ci-dessus ; seul Firebase:StorageBucket
|
||
// s'ajoute à la configuration. Non configuré, le service ne supprime rien
|
||
// et le dit — il ne fait jamais semblant d'avoir nettoyé.
|
||
services.AddSingleton<IResourceBlobService, ResourceBlobService>();
|
||
services.AddHttpClient();
|
||
services.AddScoped<AgendaSyncService>();
|
||
services.AddScoped<WeatherSyncService>();
|
||
services.AddScoped<TrialLifecycleService>();
|
||
services.AddScoped<VisitEventPurgeService>();
|
||
services.AddScoped<VisitorQuestionPurgeService>();
|
||
services.AddScoped<QuestionThemingService>();
|
||
|
||
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
||
|
||
// Hangfire
|
||
services.AddHangfire(config => config
|
||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||
.UseSimpleAssemblyNameTypeSerializer()
|
||
.UseRecommendedSerializerSettings()
|
||
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString)));
|
||
services.AddHangfireServer(options => options.ServerName = "default");
|
||
|
||
// Serveur séparé pour l'ingestion : le défaut lance min(nbCPU × 5, 20) workers, et
|
||
// autant d'extractions simultanées saturent la RAM d'un conteneur qui sert aussi l'API.
|
||
// Un serveur à part plutôt qu'une queue de plus, sinon les jobs d'ingestion occupent
|
||
// les workers de la file générale (sync agenda, météo, e-mails).
|
||
services.AddHangfireServer(options =>
|
||
{
|
||
options.ServerName = IngestionService.QueueName;
|
||
options.Queues = new[] { IngestionService.QueueName };
|
||
options.WorkerCount = 2;
|
||
});
|
||
|
||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||
dataSourceBuilder.UseNetTopologySuite();
|
||
dataSourceBuilder.UseVector();
|
||
dataSourceBuilder.EnableDynamicJson();
|
||
var dataSource = dataSourceBuilder.Build();
|
||
|
||
// Scoped, pas singleton : l'intercepteur accumule les sections touchées entre
|
||
// SavingChanges et SavedChanges. Partagé entre requêtes, deux clients se
|
||
// déclencheraient mutuellement des ré-indexations.
|
||
services.AddScoped<SectionIndexingInterceptor>();
|
||
|
||
services.AddDbContext<MyInfoMateDbContext>((serviceProvider, options) =>
|
||
{
|
||
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
|
||
.AddInterceptors(serviceProvider.GetRequiredService<SectionIndexingInterceptor>());
|
||
#if DEBUG
|
||
// Écrit les valeurs des paramètres dans les logs : mots de passe hashés,
|
||
// clés API, données de visiteurs. Jamais en production — le Dockerfile
|
||
// publie en `-c Release`, donc ce bloc n'y est pas compilé.
|
||
options.EnableSensitiveDataLogging()
|
||
.LogTo(Console.WriteLine, LogLevel.Information);
|
||
#endif
|
||
}
|
||
);
|
||
|
||
services.AddHealthChecks()
|
||
.AddNpgSql(connectionString);
|
||
|
||
// La clé API publique d'une instance est embarquée dans les apps visiteur et
|
||
// lisible en clair dans le navigateur sur visitapp-web : elle est publique par
|
||
// construction. Les endpoints IA sont les seuls qui coûtent de l'argent réel
|
||
// (jetons Gemini), et rien n'empêchait d'y boucler jusqu'à vider le quota mensuel
|
||
// d'un client qui n'a rien fait.
|
||
//
|
||
// Partition par instance, parce que c'est l'instance qui porte le quota qu'on
|
||
// protège : l'abus chez un client ne doit pas ralentir les autres. Le plafond est
|
||
// large — un musée à 100 visiteurs simultanés posant une question par minute
|
||
// reste très en dessous — il coupe la boucle automatique, pas l'usage réel.
|
||
services.AddRateLimiter(options =>
|
||
{
|
||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||
|
||
options.OnRejected = (context, _) =>
|
||
{
|
||
context.HttpContext.Response.Headers.RetryAfter = "60";
|
||
return ValueTask.CompletedTask;
|
||
};
|
||
|
||
options.AddPolicy(AiController.RateLimitPolicy, httpContext =>
|
||
RateLimitPartition.GetFixedWindowLimiter(
|
||
httpContext.User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value
|
||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||
?? "unknown",
|
||
_ => new FixedWindowRateLimiterOptions
|
||
{
|
||
PermitLimit = 120,
|
||
Window = TimeSpan.FromMinutes(1)
|
||
}));
|
||
});
|
||
}
|
||
|
||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||
{
|
||
/*app.UseCors(
|
||
options => options.WithOrigins("http://localhost:49430").AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials()
|
||
);*/
|
||
|
||
if (env.IsDevelopment())
|
||
{
|
||
app.UseDeveloperExceptionPage();
|
||
}
|
||
|
||
app.UseExceptionHandler(HandleError);
|
||
app.UseSerilogRequestLogging();
|
||
|
||
//app.UseHttpsRedirection();
|
||
|
||
app.UseRouting();
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
//app.UseCors("AllowAll");
|
||
|
||
app.UseCors(
|
||
#if DEBUG
|
||
options => options
|
||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090" || origin == "http://localhost:3000")
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader()
|
||
.AllowCredentials()
|
||
#else
|
||
options => options
|
||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "https://manager.mymuseum.be" || origin == "https://manager.myinfomate.be" || origin == "https://visitnamur.myinfomate.be" || origin == "https://fortsaintheribert.myinfomate.be" || origin == "https://app.myinfomate.be" || origin == "https://myinfomate.be")
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader()
|
||
.AllowCredentials()
|
||
#endif
|
||
);
|
||
|
||
// Après UseCors : un 429 renvoyé avant que les en-têtes CORS soient posés
|
||
// s'affiche comme une erreur CORS dans le navigateur, et visitapp-web ne verrait
|
||
// jamais le vrai code. Après UseAuthentication aussi, sinon la partition n'a pas
|
||
// encore le claim d'instance et tout le monde tomberait dans le même seau.
|
||
app.UseRateLimiter();
|
||
|
||
#if DEBUG
|
||
app.UseHangfireDashboard("/hangfire");
|
||
#else
|
||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||
{
|
||
Authorization = new[] { new HangfireDashboardAuthorizationFilter() }
|
||
});
|
||
#endif
|
||
|
||
RecurringJob.AddOrUpdate<AgendaSyncService>(
|
||
"agenda-sync-daily",
|
||
s => s.SyncAllAsync(),
|
||
Cron.Daily());
|
||
|
||
RecurringJob.AddOrUpdate<WeatherSyncService>(
|
||
"weather-sync-morning",
|
||
s => s.SyncAllAsync(),
|
||
"0 6 * * *");
|
||
|
||
RecurringJob.AddOrUpdate<WeatherSyncService>(
|
||
"weather-sync-afternoon",
|
||
s => s.SyncAllAsync(),
|
||
"0 13 * * *");
|
||
|
||
RecurringJob.AddOrUpdate<TrialLifecycleService>(
|
||
"trial-lifecycle-daily",
|
||
s => s.RunAsync(),
|
||
Cron.Daily());
|
||
|
||
// Ne supprime rien tant que Stats:RetentionDays n'est pas défini — voir VisitEventPurgeService.
|
||
RecurringJob.AddOrUpdate<VisitEventPurgeService>(
|
||
"visit-events-purge",
|
||
s => s.PurgeAsync(),
|
||
"0 3 * * *");
|
||
|
||
// ⚠️ Le regroupement passe AVANT la purge dans la nuit — 2 h contre 3 h 30. Une
|
||
// question purgée avant d'avoir été classée ne compte dans aucun agrégat, et rien
|
||
// ne peut la rattraper : elle n'existe plus.
|
||
RecurringJob.AddOrUpdate<QuestionThemingService>(
|
||
"visitor-questions-theming",
|
||
s => s.RunAsync(),
|
||
"0 2 * * *");
|
||
|
||
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
|
||
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
|
||
"visitor-questions-purge",
|
||
s => s.PurgeAsync(),
|
||
"30 3 * * *");
|
||
|
||
app.UseEndpoints(endpoints =>
|
||
{
|
||
endpoints.MapControllers();
|
||
endpoints.MapHealthChecks("/health");
|
||
});
|
||
|
||
app.UseOpenApi();
|
||
app.UseSwaggerUi3(configure =>
|
||
{
|
||
configure.OperationsSorter = "alpha";
|
||
configure.TagsSorter = "alpha";
|
||
});
|
||
}
|
||
|
||
private void ConfigureSwagger(AspNetCoreOpenApiDocumentGeneratorSettings config)
|
||
{
|
||
config.GenerateEnumMappingDescription = true;
|
||
config.AddSecurity("bearer", Enumerable.Empty<string>(), new OpenApiSecurityScheme
|
||
{
|
||
Type = OpenApiSecuritySchemeType.OAuth2,
|
||
Description = "Manager Authentication",
|
||
Flow = OpenApiOAuth2Flow.Password,
|
||
Flows = new OpenApiOAuthFlows()
|
||
{
|
||
|
||
Password = new OpenApiOAuthFlow()
|
||
{
|
||
Scopes = new Dictionary<string, string>
|
||
{
|
||
{ ManagerService.Service.Security.Scope, "Manager WebAPI" }
|
||
},
|
||
TokenUrl = "/api/authentication/Token",
|
||
AuthorizationUrl = "/authentication/Token",
|
||
}
|
||
}
|
||
});
|
||
config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("bearer"));
|
||
|
||
config.AddSecurity("apikey", Enumerable.Empty<string>(), new OpenApiSecurityScheme
|
||
{
|
||
Type = OpenApiSecuritySchemeType.ApiKey,
|
||
Name = "X-Api-Key",
|
||
In = OpenApiSecurityApiKeyLocation.Header,
|
||
Description = "API Key for mobile apps"
|
||
});
|
||
|
||
config.PostProcess = document =>
|
||
{
|
||
document.Info.Title = "Manager Service";
|
||
document.Info.Description = "API Manager Service";
|
||
document.Info.Version = "Version Alpha";
|
||
};
|
||
}
|
||
|
||
|
||
private void HandleError(IApplicationBuilder error)
|
||
{
|
||
error.Run(async context =>
|
||
{
|
||
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
|
||
var exception = exceptionHandlerPathFeature?.Error as RequestException;
|
||
|
||
if (exception != null)
|
||
{
|
||
var json = exception.GetJson();
|
||
context.Response.ContentType = "application/json";
|
||
context.Response.StatusCode = exception.StatusCode;
|
||
await context.Response.WriteAsync(json);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|