feature: Elasticsearch CQRS Projection and Analytics Endpoints

This commit is contained in:
voltsrage
2026-06-17 00:05:37 +08:00
parent 84d259d10c
commit fde3d56484
21 changed files with 1281 additions and 2 deletions
@@ -0,0 +1,94 @@
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.IndexManagement;
using Microsoft.Extensions.Options;
public class ElasticIndexProvisioner : IHostedService
{
private readonly ElasticsearchClient _elastic;
private readonly ElasticsearchOptions _options;
private readonly ILogger<ElasticIndexProvisioner> _logger;
public ElasticIndexProvisioner(
ElasticsearchClient elastic,
IOptions<ElasticsearchOptions> options,
ILogger<ElasticIndexProvisioner> logger)
{
_elastic = elastic;
_options = options.Value;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await EnsureIndexAsync<PatientEncounterDocument>(
_options.Indices.PatientEncounters, BuildPatientEncountersMapping());
await EnsureIndexAsync<ObservationDocument>(
_options.Indices.Observations, BuildObservationsMapping());
await EnsureIndexAsync<ClinicalAlertDocument>(
_options.Indices.ClinicalAlerts, BuildClinicalAlertsMapping());
}
private async Task EnsureIndexAsync<T>(
string indexName,
Action<CreateIndexRequestDescriptor<T>> configure) where T : class
{
var exists = await _elastic.Indices.ExistsAsync(indexName);
if (exists.Exists)
{
_logger.LogInformation("Elasticsearch index '{Index}' already exists — skipping", indexName);
return;
}
var resp = await _elastic.Indices.CreateAsync<T>(indexName, configure);
if (!resp.IsValidResponse)
throw new InvalidOperationException(
$"Failed to create Elasticsearch index '{indexName}': {resp.DebugInformation}");
_logger.LogInformation("Created Elasticsearch index '{Index}'", indexName);
}
private Action<CreateIndexRequestDescriptor<PatientEncounterDocument>> BuildPatientEncountersMapping() =>
d => d.Mappings(m => m.Properties(p => p
.Keyword(k => k.EncounterId)
.Keyword(k => k.PatientId)
.Keyword(k => k.Mrn)
// text for full-text search + keyword sub-field for exact sort/filter
.Text(t => t.PatientName, tf => tf
.Fields(f => f.Keyword(k => k.PatientName)))
.Keyword(k => k.Department)
.Keyword(k => k.Status)
.Keyword(k => k.AttendingPhysician)
.Date(d => d.AdmittedAt)
.IntegerNumber(i => i.OpenAlertCount)
.Date(d => d.LastObservationAt!)
));
private Action<CreateIndexRequestDescriptor<ObservationDocument>> BuildObservationsMapping() =>
d => d.Mappings(m => m.Properties(p => p
.Keyword(k => k.ObservationId)
.Keyword(k => k.EncounterId)
.Keyword(k => k.PatientId)
.Keyword(k => k.Mrn)
.Keyword(k => k.ObservationCode)
// float: observations are decimal values like 97.3, 2.5, 118.0
// auto-mapped 'long' would truncate fractional parts silently
.FloatNumber(f => f.Value)
.Keyword(k => k.Unit)
.Keyword(k => k.Source)
.Date(d => d.RecordedAt)
));
private Action<CreateIndexRequestDescriptor<ClinicalAlertDocument>> BuildClinicalAlertsMapping() =>
d => d.Mappings(m => m.Properties(p => p
.Keyword(k => k.AlertId)
.Keyword(k => k.EncounterId)
.Keyword(k => k.PatientId)
.Keyword(k => k.Department)
.Keyword(k => k.AlertType)
.Keyword(k => k.Severity)
.Keyword(k => k.Status)
.Date(d => d.TriggeredAt)
));
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}