feat: replace Redis with PostgreSQL for caching, rate limiting, SMS codes, and token blacklist
- Add 4 PG entities: VerificationCode, RateLimitEntry, TokenBlacklistEntry, CacheEntry - Add 4 services: VerificationService, RateLimitService, TokenBlacklistService, CacheService - Add CleanupBackgroundService for periodic expired data cleanup - Add MigrationHelper for safe schema migration without data loss - Update AuthController: real SMS code generation, rate limiting, logout endpoint with JWT blacklist - Update JwtProvider: add JTI claim for token revocation - Update Program.cs: register new services, JWT blacklist validation, DB migration - Remove StackExchange.Redis NuGet package and all Redis config references - Update start-dev.bat: 6→5 services, remove Redis startup - Update docs: remove Redis references from all documentation - Fix: logout button spacing on profile page - Fix: .gitignore data/→/data/ to not ignore Infrastructure/Data/
This commit is contained in:
193
backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs
Normal file
193
backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using HealthManager.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HealthManager.Infrastructure.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
public DbSet<Medication> Medications => Set<Medication>();
|
||||
public DbSet<MedicationRecord> MedicationRecords => Set<MedicationRecord>();
|
||||
public DbSet<Consultation> Consultations => Set<Consultation>();
|
||||
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
|
||||
public DbSet<QuickReplyTemplate> QuickReplyTemplates => Set<QuickReplyTemplate>();
|
||||
public DbSet<Report> Reports => Set<Report>();
|
||||
public DbSet<ReportItem> ReportItems => Set<ReportItem>();
|
||||
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||
public DbSet<ExerciseRecord> ExerciseRecords => Set<ExerciseRecord>();
|
||||
public DbSet<DietRecord> DietRecords => Set<DietRecord>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||
public DbSet<RateLimitEntry> RateLimitEntries => Set<RateLimitEntry>();
|
||||
public DbSet<TokenBlacklistEntry> TokenBlacklistEntries => Set<TokenBlacklistEntry>();
|
||||
public DbSet<CacheEntry> CacheEntries => Set<CacheEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// User
|
||||
modelBuilder.Entity<User>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.Role);
|
||||
e.HasIndex(u => u.Phone).IsUnique();
|
||||
e.Property(u => u.MedicalHistory).HasColumnType("text[]");
|
||||
e.Property(u => u.Specialty).HasColumnType("text[]");
|
||||
});
|
||||
|
||||
// RefreshToken
|
||||
modelBuilder.Entity<RefreshToken>(e =>
|
||||
{
|
||||
e.HasIndex(rt => rt.Token).IsUnique();
|
||||
e.HasOne(rt => rt.User).WithMany(u => u.RefreshTokens).HasForeignKey(rt => rt.UserId);
|
||||
});
|
||||
|
||||
// HealthRecord
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
{
|
||||
e.HasIndex(hr => new { hr.UserId, hr.Type });
|
||||
e.HasIndex(hr => hr.RecordedAt);
|
||||
e.Property(hr => hr.Value)
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v.RootElement.GetRawText(),
|
||||
v => JsonDocument.Parse(v, default));
|
||||
e.HasOne(hr => hr.User).WithMany(u => u.HealthRecords).HasForeignKey(hr => hr.UserId);
|
||||
});
|
||||
|
||||
// Device
|
||||
modelBuilder.Entity<Device>(e =>
|
||||
{
|
||||
e.HasIndex(d => d.UserId);
|
||||
});
|
||||
|
||||
// Medication
|
||||
modelBuilder.Entity<Medication>(e =>
|
||||
{
|
||||
e.HasIndex(m => m.UserId);
|
||||
e.HasIndex(m => m.Status);
|
||||
e.Property(m => m.TimeSlots).HasColumnType("text[]");
|
||||
e.HasOne(m => m.User).WithMany(u => u.Medications).HasForeignKey(m => m.UserId);
|
||||
e.HasOne(m => m.Doctor).WithMany().HasForeignKey(m => m.DoctorId);
|
||||
});
|
||||
|
||||
// MedicationRecord
|
||||
modelBuilder.Entity<MedicationRecord>(e =>
|
||||
{
|
||||
e.HasIndex(mr => new { mr.UserId, mr.CreatedAt });
|
||||
e.HasOne(mr => mr.Medication).WithMany(m => m.Records).HasForeignKey(mr => mr.MedicationId);
|
||||
e.HasOne(mr => mr.User).WithMany(u => u.MedicationRecords).HasForeignKey(mr => mr.UserId);
|
||||
});
|
||||
|
||||
// Consultation
|
||||
modelBuilder.Entity<Consultation>(e =>
|
||||
{
|
||||
e.HasIndex(c => c.PatientId);
|
||||
e.HasIndex(c => c.DoctorId);
|
||||
e.HasIndex(c => c.Status);
|
||||
// Prevent duplicate active consultations between same patient+doctor
|
||||
e.HasIndex(c => new { c.PatientId, c.DoctorId }).IsUnique()
|
||||
.HasFilter("\"Status\" = 'active'");
|
||||
e.HasOne(c => c.Patient).WithMany().HasForeignKey(c => c.PatientId);
|
||||
e.HasOne(c => c.Doctor).WithMany().HasForeignKey(c => c.DoctorId);
|
||||
});
|
||||
|
||||
// ConsultationMessage
|
||||
modelBuilder.Entity<ConsultationMessage>(e =>
|
||||
{
|
||||
e.HasIndex(cm => cm.ConsultationId);
|
||||
e.HasOne(cm => cm.Consultation).WithMany(c => c.Messages).HasForeignKey(cm => cm.ConsultationId);
|
||||
e.HasOne(cm => cm.Sender).WithMany().HasForeignKey(cm => cm.SenderId);
|
||||
});
|
||||
|
||||
// QuickReplyTemplate
|
||||
modelBuilder.Entity<QuickReplyTemplate>(e =>
|
||||
{
|
||||
e.HasIndex(t => t.DoctorId);
|
||||
e.HasOne(t => t.Doctor).WithMany().HasForeignKey(t => t.DoctorId);
|
||||
});
|
||||
|
||||
// Report
|
||||
modelBuilder.Entity<Report>(e =>
|
||||
{
|
||||
e.HasIndex(r => r.PatientId);
|
||||
e.HasIndex(r => r.Status);
|
||||
e.Property(r => r.ImageUrls).HasColumnType("text[]");
|
||||
e.HasOne(r => r.Patient).WithMany().HasForeignKey(r => r.PatientId);
|
||||
e.HasOne(r => r.Doctor).WithMany().HasForeignKey(r => r.DoctorId);
|
||||
});
|
||||
|
||||
// ReportItem
|
||||
modelBuilder.Entity<ReportItem>(e =>
|
||||
{
|
||||
e.HasOne(ri => ri.Report).WithMany(r => r.Items).HasForeignKey(ri => ri.ReportId);
|
||||
});
|
||||
|
||||
// FollowUp
|
||||
modelBuilder.Entity<FollowUp>(e =>
|
||||
{
|
||||
e.HasIndex(f => f.PatientId);
|
||||
e.HasIndex(f => f.DoctorId);
|
||||
e.HasIndex(f => f.ScheduledAt);
|
||||
e.HasOne(f => f.Patient).WithMany().HasForeignKey(f => f.PatientId);
|
||||
e.HasOne(f => f.Doctor).WithMany().HasForeignKey(f => f.DoctorId);
|
||||
});
|
||||
|
||||
// ExerciseRecord
|
||||
modelBuilder.Entity<ExerciseRecord>(e =>
|
||||
{
|
||||
e.HasIndex(er => new { er.UserId, er.RecordedAt });
|
||||
e.HasOne(er => er.User).WithMany(u => u.ExerciseRecords).HasForeignKey(er => er.UserId);
|
||||
});
|
||||
|
||||
// DietRecord
|
||||
modelBuilder.Entity<DietRecord>(e =>
|
||||
{
|
||||
e.HasIndex(dr => new { dr.UserId, dr.RecordedAt });
|
||||
e.HasOne(dr => dr.User).WithMany(u => u.DietRecords).HasForeignKey(dr => dr.UserId);
|
||||
});
|
||||
|
||||
// Notification
|
||||
modelBuilder.Entity<Notification>(e =>
|
||||
{
|
||||
e.HasIndex(n => new { n.UserId, n.IsRead });
|
||||
e.HasOne(n => n.User).WithMany(u => u.Notifications).HasForeignKey(n => n.UserId);
|
||||
});
|
||||
|
||||
// VerificationCode
|
||||
modelBuilder.Entity<VerificationCode>(e =>
|
||||
{
|
||||
e.HasIndex(vc => vc.ExpiresAt);
|
||||
e.HasIndex(vc => new { vc.Phone, vc.Type });
|
||||
});
|
||||
|
||||
// RateLimitEntry
|
||||
modelBuilder.Entity<RateLimitEntry>(e =>
|
||||
{
|
||||
e.HasIndex(rl => rl.ExpiresAt);
|
||||
e.HasIndex(rl => new { rl.Key, rl.WindowStart }).IsUnique();
|
||||
});
|
||||
|
||||
// TokenBlacklistEntry
|
||||
modelBuilder.Entity<TokenBlacklistEntry>(e =>
|
||||
{
|
||||
e.HasIndex(tb => tb.Jti).IsUnique();
|
||||
e.HasIndex(tb => tb.ExpiresAt);
|
||||
});
|
||||
|
||||
// CacheEntry
|
||||
modelBuilder.Entity<CacheEntry>(e =>
|
||||
{
|
||||
e.HasIndex(ce => ce.Key).IsUnique();
|
||||
e.HasIndex(ce => ce.ExpiresAt);
|
||||
e.Property(ce => ce.Value)
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v.RootElement.GetRawText(),
|
||||
v => JsonDocument.Parse(v, default));
|
||||
});
|
||||
}
|
||||
}
|
||||
41
backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs
Normal file
41
backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using HealthManager.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HealthManager.Infrastructure.Data;
|
||||
|
||||
public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
if (await db.Users.AnyAsync()) return;
|
||||
|
||||
var demoPassword = HashPassword("demo123");
|
||||
|
||||
var doctor = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Role = "doctor",
|
||||
Phone = "13700137000",
|
||||
PasswordHash = demoPassword,
|
||||
Name = "王建国",
|
||||
Gender = "男",
|
||||
Department = "心血管内科",
|
||||
Title = "主任医师",
|
||||
Specialty = ["冠心病", "高血压", "介入治疗"],
|
||||
Introduction = "从事心血管内科临床工作30年,擅长冠心病介入治疗及术后管理。",
|
||||
IsAvailable = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.Users.Add(doctor);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string HashPassword(string password)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HealthManager.Infrastructure.Data;
|
||||
|
||||
public static class MigrationHelper
|
||||
{
|
||||
public static async Task EnsureNewTablesAsync(AppDbContext db)
|
||||
{
|
||||
var sql = """
|
||||
CREATE TABLE IF NOT EXISTS "VerificationCodes" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"Phone" text NOT NULL,
|
||||
"Code" text NOT NULL,
|
||||
"Type" text NOT NULL DEFAULT 'login',
|
||||
"ExpiresAt" timestamptz NOT NULL,
|
||||
"IsUsed" boolean NOT NULL DEFAULT FALSE,
|
||||
"CreatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "IX_VerificationCodes_ExpiresAt" ON "VerificationCodes" ("ExpiresAt");
|
||||
CREATE INDEX IF NOT EXISTS "IX_VerificationCodes_Phone_Type" ON "VerificationCodes" ("Phone", "Type");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RateLimitEntries" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"Key" text NOT NULL,
|
||||
"Count" integer NOT NULL,
|
||||
"WindowStart" timestamptz NOT NULL,
|
||||
"ExpiresAt" timestamptz NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_RateLimitEntries_Key_WindowStart" ON "RateLimitEntries" ("Key", "WindowStart");
|
||||
CREATE INDEX IF NOT EXISTS "IX_RateLimitEntries_ExpiresAt" ON "RateLimitEntries" ("ExpiresAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "TokenBlacklistEntries" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"Jti" text NOT NULL,
|
||||
"UserId" uuid NOT NULL,
|
||||
"ExpiresAt" timestamptz NOT NULL,
|
||||
"CreatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TokenBlacklistEntries_Jti" ON "TokenBlacklistEntries" ("Jti");
|
||||
CREATE INDEX IF NOT EXISTS "IX_TokenBlacklistEntries_ExpiresAt" ON "TokenBlacklistEntries" ("ExpiresAt");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "CacheEntries" (
|
||||
"Id" uuid PRIMARY KEY,
|
||||
"Key" text NOT NULL,
|
||||
"Value" jsonb NOT NULL,
|
||||
"ExpiresAt" timestamptz NOT NULL,
|
||||
"CreatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CacheEntries_Key" ON "CacheEntries" ("Key");
|
||||
CREATE INDEX IF NOT EXISTS "IX_CacheEntries_ExpiresAt" ON "CacheEntries" ("ExpiresAt");
|
||||
""";
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||
<PackageReference Include="Minio" Version="7.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.13.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public class JwtProvider(IConfiguration configuration) : IJwtProvider
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim(ClaimTypes.Name, name),
|
||||
new Claim(ClaimTypes.Role, role),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
|
||||
Reference in New Issue
Block a user