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:
MingNian
2026-05-26 13:48:53 +08:00
parent 39ab6062b5
commit d5f167167a
25 changed files with 613 additions and 47 deletions

View 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));
});
}
}

View 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);
}
}

View File

@@ -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);
}
}