initial commit

This commit is contained in:
voltsrage
2026-06-26 04:20:20 +08:00
commit 869006e5e7
64 changed files with 4632 additions and 0 deletions
@@ -0,0 +1,62 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
public class AuthService : IAuthService
{
private readonly AppDbContext _db;
private readonly JwtOptions _jwtOptions;
public AuthService(AppDbContext db, IOptions<JwtOptions> jwtOptions)
{
_db = db;
_jwtOptions = jwtOptions.Value;
}
public async Task<LoginResponse> LoginAsync(LoginRequest req)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == req.Username);
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
throw new ValidationException("Invalid username or password.", "INVALID_CREDENTIALS");
if (!user.IsActive)
throw new ConflictException("Account is disabled.", "ACCOUNT_DISABLED");
var token = GenerateJwt(user);
return new LoginResponse(token, user.Id, user.Username, user.FullName, user.Role.ToDbString());
}
public async Task<User> GetCurrentUserAsync(Guid userId)
{
var user = await _db.Users.FindAsync(userId);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
return user;
}
private string GenerateJwt(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role.ToDbString()),
new Claim("fullName", user.FullName)
};
var token = new JwtSecurityToken(
issuer: _jwtOptions.Issuer,
audience: _jwtOptions.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiryMinutes),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,182 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
public class BatchService : IBatchService
{
private static readonly Dictionary<BatchStatus, HashSet<BatchStatus>> _allowedTransitions = new()
{
[BatchStatus.Uploaded] = new() { BatchStatus.InEntry },
[BatchStatus.InEntry] = new() { BatchStatus.PendingVerification },
[BatchStatus.PendingVerification] = new() { BatchStatus.Verified, BatchStatus.Rejected, BatchStatus.AwaitingClinicalApproval },
[BatchStatus.Rejected] = new() { BatchStatus.InEntry },
[BatchStatus.Verified] = new() { BatchStatus.Approved },
[BatchStatus.AwaitingClinicalApproval] = new() { BatchStatus.Approved, BatchStatus.Rejected },
[BatchStatus.Approved] = new() { BatchStatus.Promoted },
[BatchStatus.Promoted] = new(),
};
private readonly AppDbContext _db;
private readonly IDocumentStorageService _storage;
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<BatchService> _logger;
public BatchService(AppDbContext db, IDocumentStorageService storage,
IConnectionMultiplexer redis, ILogger<BatchService> logger)
{
_db = db;
_storage = storage;
_redis = redis;
_logger = logger;
}
public async Task<DigitizationBatch> CreateAsync(
Stream fileStream, string contentType, BatchType batchType,
BatchTrack track, Guid? patientId, Guid actorUserId)
{
var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid());
// Duplicate detection: same SHA-256 for same patient within 24 hours.
// Cross-patient duplicates (same form scanned for two patients) are allowed.
if (patientId.HasValue)
{
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
var duplicate = await _db.DigitizationBatches.AnyAsync(b =>
b.DocumentSha256 == sha256 &&
b.PatientId == patientId.Value &&
b.CreatedAt >= cutoff);
if (duplicate)
throw new ConflictException(
"A document with the same content was uploaded for this patient within the last 24 hours.",
"DUPLICATE_DOCUMENT");
}
var batchId = Guid.NewGuid();
var batch = new DigitizationBatch
{
Id = batchId,
Status = BatchStatus.Uploaded,
BatchType = batchType,
Track = track,
PatientId = patientId,
DocumentRef = objectKey,
DocumentSha256 = sha256,
EnableRetroactiveAlerts = false,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var document = new ScannedDocument
{
Id = Guid.NewGuid(),
BatchId = batchId,
ObjectKey = objectKey,
Sha256 = sha256,
ContentType = contentType,
FileSizeBytes = fileSize,
UploadedAt = DateTimeOffset.UtcNow
};
var evt = new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Uploaded,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow
};
_db.DigitizationBatches.Add(batch);
_db.ScannedDocuments.Add(document);
_db.DigitizationEvents.Add(evt);
await _db.SaveChangesAsync();
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
return batch;
}
public async Task<DigitizationBatch> GetByIdAsync(Guid id)
{
var batch = await _db.DigitizationBatches
.Include(b => b.Document)
.Include(b => b.DraftPatient)
.Include(b => b.DraftEncounter)
.Include(b => b.DraftObservations)
.FirstOrDefaultAsync(b => b.Id == id);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
return batch;
}
public async Task<PagedResult<DigitizationBatch>> ListAsync(
BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track,
int page, int pageSize)
{
var query = _db.DigitizationBatches.AsQueryable();
if (status.HasValue)
query = query.Where(b => b.Status == status.Value);
if (batchType.HasValue)
query = query.Where(b => b.BatchType == batchType.Value);
if (assignedTo.HasValue)
query = query.Where(b => b.EnteredByUserId == assignedTo.Value);
if (track.HasValue)
query = query.Where(b => b.Track == track.Value);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<DigitizationBatch>(items, page, pageSize, total);
}
public async Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId)
{
var batch = await _db.DigitizationBatches.FindAsync(batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
if (batch.Status != BatchStatus.Uploaded)
throw new ConflictException(
"Only batches in 'uploaded' status can be assigned.",
"ILLEGAL_STATUS_TRANSITION");
// Redis lock to prevent double-assignment
var cache = _redis.GetDatabase();
var lockKey = $"batch:assign:{batchId}";
var acquired = await cache.StringSetAsync(lockKey, entryClerkUserId.ToString(),
TimeSpan.FromHours(1), When.NotExists);
if (!acquired)
throw new ConflictException(
"This batch is already assigned to another clerk.",
"BATCH_ALREADY_ASSIGNED");
batch.EnteredByUserId = entryClerkUserId;
batch.UpdatedAt = DateTimeOffset.UtcNow;
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.EntryStarted,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = JsonSerializer.Serialize(new { assignedTo = entryClerkUserId })
});
await _db.SaveChangesAsync();
return batch;
}
public BatchStatus[] GetAllowedTransitions(BatchStatus current) =>
_allowedTransitions.TryGetValue(current, out var targets)
? targets.ToArray()
: Array.Empty<BatchStatus>();
}
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
public class DocumentStorageService : IDocumentStorageService
{
private readonly IMinioClient _minio;
private readonly MinioOptions _options;
public DocumentStorageService(IMinioClient minio, IOptions<MinioOptions> options)
{
_minio = minio;
_options = options.Value;
}
public async Task<(string objectKey, string sha256, long fileSize)> UploadAsync(
Stream fileStream, string contentType, Guid batchId)
{
var now = DateTimeOffset.UtcNow;
using var sha256 = SHA256.Create();
using var memStream = new MemoryStream();
await fileStream.CopyToAsync(memStream);
memStream.Position = 0;
var hashBytes = sha256.ComputeHash(memStream);
var hashHex = Convert.ToHexString(hashBytes).ToLowerInvariant();
memStream.Position = 0;
var extension = contentType switch
{
"application/pdf" => "pdf",
"image/jpeg" => "jpg",
"image/png" => "png",
_ => "bin"
};
var objectKey = $"scans/{now.Year}/{now.Month:D2}/{batchId}/{hashHex}.{extension}";
await EnsureBucketAsync();
await _minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithStreamData(memStream)
.WithObjectSize(memStream.Length)
.WithContentType(contentType));
return (objectKey, hashHex, memStream.Length);
}
public async Task<string> GetPresignedUrlAsync(string objectKey)
{
return await _minio.PresignedGetObjectAsync(new PresignedGetObjectArgs()
.WithBucket(_options.BucketName)
.WithObject(objectKey)
.WithExpiry(_options.PresignedUrlExpiryMinutes * 60));
}
private async Task EnsureBucketAsync()
{
var exists = await _minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(_options.BucketName));
if (!exists)
await _minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(_options.BucketName));
}
}
@@ -0,0 +1,5 @@
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest req);
Task<User> GetCurrentUserAsync(Guid userId);
}
@@ -0,0 +1,8 @@
public interface IBatchService
{
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
Task<DigitizationBatch> GetByIdAsync(Guid id);
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
BatchStatus[] GetAllowedTransitions(BatchStatus current);
}
@@ -0,0 +1,5 @@
public interface IDocumentStorageService
{
Task<(string objectKey, string sha256, long fileSize)> UploadAsync(Stream fileStream, string contentType, Guid batchId);
Task<string> GetPresignedUrlAsync(string objectKey);
}