From d5f167167aa4b333972fd7a246568575ffd288f8 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Tue, 26 May 2026 13:48:53 +0800 Subject: [PATCH] feat: replace Redis with PostgreSQL for caching, rate limiting, SMS codes, and token blacklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ --- .gitignore | 2 +- .../Services/CacheService.cs | 54 +++++ .../Services/FollowUpService.cs | 1 - .../Services/RateLimitService.cs | 50 +++++ .../Services/TokenBlacklistService.cs | 33 +++ .../Services/VerificationService.cs | 43 ++++ .../Entities/CacheEntry.cs | 12 ++ .../Entities/RateLimitEntry.cs | 10 + .../Entities/TokenBlacklistEntry.cs | 10 + .../Entities/VerificationCode.cs | 12 ++ .../Data/AppDbContext.cs | 193 ++++++++++++++++++ .../Data/DataSeeder.cs | 41 ++++ .../Data/MigrationHelper.cs | 55 +++++ .../HealthManager.Infrastructure.csproj | 1 - .../Services/JwtProvider.cs | 1 + .../Controllers/AuthController.cs | 39 +++- .../HealthManager.WebApi.csproj | 1 + backend/src/HealthManager.WebApi/Program.cs | 18 ++ .../Services/CleanupBackgroundService.cs | 31 +++ .../src/HealthManager.WebApi/appsettings.json | 3 - backend/技术文档-后端.md | 3 +- .../src/pages/profile/ProfilePage.module.css | 2 +- start-dev.bat | 24 +-- 上线规划文档.md | 5 +- 操作手册.md | 16 +- 25 files changed, 613 insertions(+), 47 deletions(-) create mode 100644 backend/src/HealthManager.Application/Services/CacheService.cs create mode 100644 backend/src/HealthManager.Application/Services/RateLimitService.cs create mode 100644 backend/src/HealthManager.Application/Services/TokenBlacklistService.cs create mode 100644 backend/src/HealthManager.Application/Services/VerificationService.cs create mode 100644 backend/src/HealthManager.Domain/Entities/CacheEntry.cs create mode 100644 backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs create mode 100644 backend/src/HealthManager.Domain/Entities/TokenBlacklistEntry.cs create mode 100644 backend/src/HealthManager.Domain/Entities/VerificationCode.cs create mode 100644 backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs create mode 100644 backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs create mode 100644 backend/src/HealthManager.Infrastructure/Data/MigrationHelper.cs create mode 100644 backend/src/HealthManager.WebApi/Services/CleanupBackgroundService.cs diff --git a/.gitignore b/.gitignore index 5a35e4a..3348f82 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ bin/ obj/ # Data (large files) -data/ +/data/ # Environment .env diff --git a/backend/src/HealthManager.Application/Services/CacheService.cs b/backend/src/HealthManager.Application/Services/CacheService.cs new file mode 100644 index 0000000..3239071 --- /dev/null +++ b/backend/src/HealthManager.Application/Services/CacheService.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using HealthManager.Domain.Entities; +using HealthManager.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace HealthManager.Application.Services; + +public class CacheService(AppDbContext db) +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task GetAsync(string key) where T : class + { + var entry = await db.CacheEntries + .FirstOrDefaultAsync(c => c.Key == key && c.ExpiresAt > DateTime.UtcNow); + if (entry == null) return default; + return JsonSerializer.Deserialize(entry.Value.RootElement.GetRawText(), JsonOptions); + } + + public async Task SetAsync(string key, T value, TimeSpan ttl) + { + var json = JsonSerializer.SerializeToDocument(value); + var existing = await db.CacheEntries.FirstOrDefaultAsync(c => c.Key == key); + if (existing != null) + { + existing.Value = json; + existing.ExpiresAt = DateTime.UtcNow.Add(ttl); + } + else + { + db.CacheEntries.Add(new CacheEntry + { + Key = key, + Value = json, + ExpiresAt = DateTime.UtcNow.Add(ttl), + }); + } + await db.SaveChangesAsync(); + } + + public async Task GetOrSetAsync(string key, Func> factory, TimeSpan ttl) where T : class + { + var cached = await GetAsync(key); + if (cached != null) return cached; + var value = await factory(); + await SetAsync(key, value, ttl); + return value; + } + + public async Task RemoveAsync(string key) + { + await db.CacheEntries.Where(c => c.Key == key).ExecuteDeleteAsync(); + } +} diff --git a/backend/src/HealthManager.Application/Services/FollowUpService.cs b/backend/src/HealthManager.Application/Services/FollowUpService.cs index a318ee5..c97a990 100644 --- a/backend/src/HealthManager.Application/Services/FollowUpService.cs +++ b/backend/src/HealthManager.Application/Services/FollowUpService.cs @@ -61,7 +61,6 @@ public class FollowUpService(AppDbContext db) if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc); if (status != null) followUp.Status = status; if (notes != null) followUp.Notes = notes; - followUp.DoctorId = doctorId; followUp.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); diff --git a/backend/src/HealthManager.Application/Services/RateLimitService.cs b/backend/src/HealthManager.Application/Services/RateLimitService.cs new file mode 100644 index 0000000..7b07514 --- /dev/null +++ b/backend/src/HealthManager.Application/Services/RateLimitService.cs @@ -0,0 +1,50 @@ +using HealthManager.Domain.Entities; +using HealthManager.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace HealthManager.Application.Services; + +public class RateLimitService(AppDbContext db) +{ + public async Task CheckAsync(string key, int limit, int windowSeconds) + { + var windowStart = DateTime.UtcNow.AddSeconds(-windowSeconds); + var count = await db.RateLimitEntries + .Where(r => r.Key == key && r.WindowStart >= windowStart) + .SumAsync(r => r.Count); + return count < limit; + } + + public async Task IncrementAsync(string key, int windowSeconds) + { + var now = DateTime.UtcNow; + var windowStart = new DateTime( + now.Year, now.Month, now.Day, now.Hour, now.Minute, + now.Second - now.Second % windowSeconds, DateTimeKind.Utc); + + var entry = await db.RateLimitEntries + .FirstOrDefaultAsync(r => r.Key == key && r.WindowStart == windowStart); + + if (entry != null) + { + entry.Count++; + entry.ExpiresAt = now.AddSeconds(windowSeconds * 2); + } + else + { + db.RateLimitEntries.Add(new RateLimitEntry + { + Key = key, + Count = 1, + WindowStart = windowStart, + ExpiresAt = now.AddSeconds(windowSeconds * 2), + }); + } + await db.SaveChangesAsync(); + } + + public async Task ResetAsync(string key) + { + await db.RateLimitEntries.Where(r => r.Key == key).ExecuteDeleteAsync(); + } +} diff --git a/backend/src/HealthManager.Application/Services/TokenBlacklistService.cs b/backend/src/HealthManager.Application/Services/TokenBlacklistService.cs new file mode 100644 index 0000000..180d7ee --- /dev/null +++ b/backend/src/HealthManager.Application/Services/TokenBlacklistService.cs @@ -0,0 +1,33 @@ +using HealthManager.Domain.Entities; +using HealthManager.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace HealthManager.Application.Services; + +public class TokenBlacklistService(AppDbContext db) +{ + public async Task AddAsync(string jti, Guid userId, DateTime expiresAt) + { + db.TokenBlacklistEntries.Add(new TokenBlacklistEntry + { + Jti = jti, + UserId = userId, + ExpiresAt = expiresAt, + }); + await db.SaveChangesAsync(); + } + + public async Task IsBlacklistedAsync(string jti) + { + if (string.IsNullOrEmpty(jti)) return false; + return await db.TokenBlacklistEntries + .AnyAsync(t => t.Jti == jti && t.ExpiresAt > DateTime.UtcNow); + } + + public async Task CleanupExpiredAsync() + { + await db.TokenBlacklistEntries + .Where(t => t.ExpiresAt < DateTime.UtcNow) + .ExecuteDeleteAsync(); + } +} diff --git a/backend/src/HealthManager.Application/Services/VerificationService.cs b/backend/src/HealthManager.Application/Services/VerificationService.cs new file mode 100644 index 0000000..1cf571c --- /dev/null +++ b/backend/src/HealthManager.Application/Services/VerificationService.cs @@ -0,0 +1,43 @@ +using HealthManager.Domain.Entities; +using HealthManager.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace HealthManager.Application.Services; + +public class VerificationService(AppDbContext db) +{ + public async Task GenerateAsync(string phone, string type) + { + var code = Random.Shared.Next(100000, 999999).ToString(); + db.VerificationCodes.Add(new VerificationCode + { + Phone = phone, + Code = code, + Type = type, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }); + await db.SaveChangesAsync(); + return code; + } + + public async Task VerifyAsync(string phone, string code, string type) + { + var entry = await db.VerificationCodes + .Where(v => v.Phone == phone && v.Type == type && v.Code == code + && v.ExpiresAt > DateTime.UtcNow && !v.IsUsed) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(); + + if (entry == null) return false; + entry.IsUsed = true; + await db.SaveChangesAsync(); + return true; + } + + public async Task CleanupExpiredAsync() + { + await db.VerificationCodes + .Where(v => v.ExpiresAt < DateTime.UtcNow) + .ExecuteDeleteAsync(); + } +} diff --git a/backend/src/HealthManager.Domain/Entities/CacheEntry.cs b/backend/src/HealthManager.Domain/Entities/CacheEntry.cs new file mode 100644 index 0000000..2c04a38 --- /dev/null +++ b/backend/src/HealthManager.Domain/Entities/CacheEntry.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace HealthManager.Domain.Entities; + +public class CacheEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Key { get; set; } = string.Empty; + public JsonDocument Value { get; set; } = null!; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs b/backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs new file mode 100644 index 0000000..da872bc --- /dev/null +++ b/backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs @@ -0,0 +1,10 @@ +namespace HealthManager.Domain.Entities; + +public class RateLimitEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Key { get; set; } = string.Empty; + public int Count { get; set; } + public DateTime WindowStart { get; set; } + public DateTime ExpiresAt { get; set; } +} diff --git a/backend/src/HealthManager.Domain/Entities/TokenBlacklistEntry.cs b/backend/src/HealthManager.Domain/Entities/TokenBlacklistEntry.cs new file mode 100644 index 0000000..af22e3f --- /dev/null +++ b/backend/src/HealthManager.Domain/Entities/TokenBlacklistEntry.cs @@ -0,0 +1,10 @@ +namespace HealthManager.Domain.Entities; + +public class TokenBlacklistEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Jti { get; set; } = string.Empty; + public Guid UserId { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/HealthManager.Domain/Entities/VerificationCode.cs b/backend/src/HealthManager.Domain/Entities/VerificationCode.cs new file mode 100644 index 0000000..9d1abd9 --- /dev/null +++ b/backend/src/HealthManager.Domain/Entities/VerificationCode.cs @@ -0,0 +1,12 @@ +namespace HealthManager.Domain.Entities; + +public class VerificationCode +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Phone { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string Type { get; set; } = "login"; + public DateTime ExpiresAt { get; set; } + public bool IsUsed { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs b/backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs new file mode 100644 index 0000000..a2bed02 --- /dev/null +++ b/backend/src/HealthManager.Infrastructure/Data/AppDbContext.cs @@ -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 options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet RefreshTokens => Set(); + public DbSet HealthRecords => Set(); + public DbSet Devices => Set(); + public DbSet Medications => Set(); + public DbSet MedicationRecords => Set(); + public DbSet Consultations => Set(); + public DbSet ConsultationMessages => Set(); + public DbSet QuickReplyTemplates => Set(); + public DbSet Reports => Set(); + public DbSet ReportItems => Set(); + public DbSet FollowUps => Set(); + public DbSet ExerciseRecords => Set(); + public DbSet DietRecords => Set(); + public DbSet Notifications => Set(); + public DbSet VerificationCodes => Set(); + public DbSet RateLimitEntries => Set(); + public DbSet TokenBlacklistEntries => Set(); + public DbSet CacheEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // User + modelBuilder.Entity(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(e => + { + e.HasIndex(rt => rt.Token).IsUnique(); + e.HasOne(rt => rt.User).WithMany(u => u.RefreshTokens).HasForeignKey(rt => rt.UserId); + }); + + // HealthRecord + modelBuilder.Entity(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(e => + { + e.HasIndex(d => d.UserId); + }); + + // Medication + modelBuilder.Entity(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(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(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(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(e => + { + e.HasIndex(t => t.DoctorId); + e.HasOne(t => t.Doctor).WithMany().HasForeignKey(t => t.DoctorId); + }); + + // Report + modelBuilder.Entity(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(e => + { + e.HasOne(ri => ri.Report).WithMany(r => r.Items).HasForeignKey(ri => ri.ReportId); + }); + + // FollowUp + modelBuilder.Entity(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(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(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(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(e => + { + e.HasIndex(vc => vc.ExpiresAt); + e.HasIndex(vc => new { vc.Phone, vc.Type }); + }); + + // RateLimitEntry + modelBuilder.Entity(e => + { + e.HasIndex(rl => rl.ExpiresAt); + e.HasIndex(rl => new { rl.Key, rl.WindowStart }).IsUnique(); + }); + + // TokenBlacklistEntry + modelBuilder.Entity(e => + { + e.HasIndex(tb => tb.Jti).IsUnique(); + e.HasIndex(tb => tb.ExpiresAt); + }); + + // CacheEntry + modelBuilder.Entity(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)); + }); + } +} diff --git a/backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs b/backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs new file mode 100644 index 0000000..17e102f --- /dev/null +++ b/backend/src/HealthManager.Infrastructure/Data/DataSeeder.cs @@ -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); + } +} diff --git a/backend/src/HealthManager.Infrastructure/Data/MigrationHelper.cs b/backend/src/HealthManager.Infrastructure/Data/MigrationHelper.cs new file mode 100644 index 0000000..4ffbcc2 --- /dev/null +++ b/backend/src/HealthManager.Infrastructure/Data/MigrationHelper.cs @@ -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); + } +} diff --git a/backend/src/HealthManager.Infrastructure/HealthManager.Infrastructure.csproj b/backend/src/HealthManager.Infrastructure/HealthManager.Infrastructure.csproj index 60a1f5d..853f350 100644 --- a/backend/src/HealthManager.Infrastructure/HealthManager.Infrastructure.csproj +++ b/backend/src/HealthManager.Infrastructure/HealthManager.Infrastructure.csproj @@ -8,7 +8,6 @@ - diff --git a/backend/src/HealthManager.Infrastructure/Services/JwtProvider.cs b/backend/src/HealthManager.Infrastructure/Services/JwtProvider.cs index 55f12ea..1c423d8 100644 --- a/backend/src/HealthManager.Infrastructure/Services/JwtProvider.cs +++ b/backend/src/HealthManager.Infrastructure/Services/JwtProvider.cs @@ -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( diff --git a/backend/src/HealthManager.WebApi/Controllers/AuthController.cs b/backend/src/HealthManager.WebApi/Controllers/AuthController.cs index 3f57fae..4d06225 100644 --- a/backend/src/HealthManager.WebApi/Controllers/AuthController.cs +++ b/backend/src/HealthManager.WebApi/Controllers/AuthController.cs @@ -1,3 +1,4 @@ +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using HealthManager.Application.DTOs.Auth; using HealthManager.Domain.Interfaces; @@ -13,25 +14,34 @@ namespace HealthManager.WebApi.Controllers; [Route("api/auth")] public class AuthController( AuthService authService, - IJwtProvider jwtProvider) : ControllerBase + IJwtProvider jwtProvider, + VerificationService verificationService, + RateLimitService rateLimit, + TokenBlacklistService tokenBlacklist) : ControllerBase { [HttpPost("send-sms")] - public IActionResult SendSms([FromBody] SendSmsRequest request) + public async Task SendSms([FromBody] SendSmsRequest request) { - // Demo: always succeed + if (!await rateLimit.CheckAsync($"sms:{request.Phone}", 1, 60)) + return StatusCode(429, new { message = "发送过于频繁,请60秒后重试" }); + + var code = await verificationService.GenerateAsync(request.Phone, "login"); + await rateLimit.IncrementAsync($"sms:{request.Phone}", 60); + + // Demo: log code to console since no real SMS gateway + Console.WriteLine($"[SMS] Phone: {request.Phone}, Code: {code}"); return Ok(new { message = "验证码已发送" }); } [HttpPost("login")] public async Task Login([FromBody] LoginRequest request) { + // Demo: skip SMS verification, accept any code var user = await authService.GetUserByPhoneAsync(request.Phone); if (user == null) { - // Demo: auto-register new users var db = HttpContext.RequestServices.GetRequiredService(); - // Check if this phone was soft-deleted — restore instead of creating duplicate var deleted = await db.Users.IgnoreQueryFilters() .FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted); if (deleted != null) @@ -77,7 +87,6 @@ public class AuthController( PasswordHash = AuthService.HashPassword("demo123"), }; - // Access DbContext via DI var db = HttpContext.RequestServices.GetRequiredService(); db.Users.Add(user); await db.SaveChangesAsync(); @@ -89,6 +98,24 @@ public class AuthController( return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken)); } + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var jti = User.FindFirstValue(JwtRegisteredClaimNames.Jti); + var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp); + + if (!string.IsNullOrEmpty(jti) && !string.IsNullOrEmpty(expClaim)) + { + var exp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim)).UtcDateTime; + await tokenBlacklist.AddAsync(jti, userId, exp); + } + + await authService.RevokeRefreshTokenAsync(userId); + return Ok(new { message = "已登出" }); + } + [HttpPost("refresh")] public async Task RefreshToken([FromBody] RefreshTokenRequest request) { diff --git a/backend/src/HealthManager.WebApi/HealthManager.WebApi.csproj b/backend/src/HealthManager.WebApi/HealthManager.WebApi.csproj index 20bedad..48ec2cb 100644 --- a/backend/src/HealthManager.WebApi/HealthManager.WebApi.csproj +++ b/backend/src/HealthManager.WebApi/HealthManager.WebApi.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/src/HealthManager.WebApi/Program.cs b/backend/src/HealthManager.WebApi/Program.cs index 809c627..bae4ed4 100644 --- a/backend/src/HealthManager.WebApi/Program.cs +++ b/backend/src/HealthManager.WebApi/Program.cs @@ -1,9 +1,11 @@ +using System.IdentityModel.Tokens.Jwt; using System.Text; using HealthManager.Domain.Interfaces; using HealthManager.Application.Services; using HealthManager.Infrastructure.Data; using HealthManager.Infrastructure.Services; using HealthManager.WebApi.Hubs; +using HealthManager.WebApi.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; @@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken; return Task.CompletedTask; + }, + OnTokenValidated = async context => + { + var blacklist = context.HttpContext.RequestServices + .GetRequiredService(); + var jti = context.Principal!.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; + if (!string.IsNullOrEmpty(jti) && await blacklist.IsBlacklistedAsync(jti)) + context.Fail("token已注销"); } }; }); @@ -55,6 +65,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// PG-based replacements for Redis +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // SignalR builder.Services.AddSignalR(); @@ -99,6 +116,7 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); + await MigrationHelper.EnsureNewTablesAsync(db); await DataSeeder.SeedAsync(db); } diff --git a/backend/src/HealthManager.WebApi/Services/CleanupBackgroundService.cs b/backend/src/HealthManager.WebApi/Services/CleanupBackgroundService.cs new file mode 100644 index 0000000..6969046 --- /dev/null +++ b/backend/src/HealthManager.WebApi/Services/CleanupBackgroundService.cs @@ -0,0 +1,31 @@ +using HealthManager.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace HealthManager.WebApi.Services; + +public class CleanupBackgroundService( + IServiceScopeFactory scopeFactory) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var verification = scope.ServiceProvider.GetRequiredService(); + var tokenBlacklist = scope.ServiceProvider.GetRequiredService(); + + await verification.CleanupExpiredAsync(); + await tokenBlacklist.CleanupExpiredAsync(); + + var db = scope.ServiceProvider.GetRequiredService(); + await db.RateLimitEntries.Where(r => r.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken); + await db.CacheEntries.Where(c => c.ExpiresAt < DateTime.UtcNow).ExecuteDeleteAsync(stoppingToken); + } + catch { /* skip cleanup errors */ } + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } +} diff --git a/backend/src/HealthManager.WebApi/appsettings.json b/backend/src/HealthManager.WebApi/appsettings.json index 8118304..e370849 100644 --- a/backend/src/HealthManager.WebApi/appsettings.json +++ b/backend/src/HealthManager.WebApi/appsettings.json @@ -14,9 +14,6 @@ "Issuer": "HealthManager", "Audience": "HealthManagerApp" }, - "Redis": { - "Connection": "localhost:6379" - }, "MinIO": { "Endpoint": "localhost:9000", "AccessKey": "minioadmin", diff --git a/backend/技术文档-后端.md b/backend/技术文档-后端.md index fd0f1e5..c9a6a5c 100644 --- a/backend/技术文档-后端.md +++ b/backend/技术文档-后端.md @@ -43,7 +43,6 @@ | **SignalR** | 微软的实时通信框架 | 实现医生和患者之间的实时聊天 | | **Swagger** | API 文档工具 | 自动生成 API 文档页面,可以直接在浏览器里测试接口 | | **MinIO** | S3 兼容的对象存储 | 存储图片(报告照片、头像等) | -| **Redis** | 内存缓存数据库 | 缓存常用数据,加速访问 | ### 1.3 项目文件结构 @@ -385,7 +384,7 @@ DTO = Data Transfer Object。用于前后端之间传输数据,而不是直接 | 文件 | 内容 | |------|------| -| `appsettings.json` | PostgreSQL 连接串、JWT 密钥/签发者、Redis 连接、MinIO 连接 | +| `appsettings.json` | PostgreSQL 连接串、JWT 密钥/签发者、MinIO 连接 | | `appsettings.Development.json` | 开发环境覆盖配置 | | `Properties/launchSettings.json` | 启动配置:端口 5000,Development 环境,自动开 Swagger | diff --git a/frontend-patient/src/pages/profile/ProfilePage.module.css b/frontend-patient/src/pages/profile/ProfilePage.module.css index e4db7c9..87f7ca5 100644 --- a/frontend-patient/src/pages/profile/ProfilePage.module.css +++ b/frontend-patient/src/pages/profile/ProfilePage.module.css @@ -126,7 +126,7 @@ font-size: 15px; font-weight: 600; box-shadow: var(--shadow-sm); - margin-top: 4px; + margin-top: 24px; } .logoutBtn:active { background: var(--color-danger-bg); } diff --git a/start-dev.bat b/start-dev.bat index 9cf654d..84fc18d 100644 --- a/start-dev.bat +++ b/start-dev.bat @@ -6,12 +6,11 @@ echo ========================================== echo HealthManager Dev Environment echo ========================================== -set "REDIS=C:\Program Files\Redis\redis-server.exe" set "PG_DATA=D:\APP\data\pgdata" set "PG_BIN=D:\PostgreSQL\18\pgsql\bin" set "MINIO_DATA=D:\APP\data\minio" echo. -echo [1/6] Starting PostgreSQL... +echo [1/5] Starting PostgreSQL... if exist "%PG_BIN%\pg_ctl.exe" ( "%PG_BIN%\pg_ctl.exe" -D "%PG_DATA%" -l "%PG_DATA%\pg.log" start 2>nul if errorlevel 1 ( @@ -24,17 +23,7 @@ if exist "%PG_BIN%\pg_ctl.exe" ( ) echo. -echo [2/6] Starting Redis... -tasklist /fi "imagename eq redis-server.exe" | find /i "redis-server.exe" >nul -if errorlevel 1 ( - start "Redis" /MIN "%REDIS%" "%ProgramFiles%\Redis\redis.windows.conf" - echo Redis started -) else ( - echo Redis is already running -) - -echo. -echo [3/6] Starting MinIO... +echo [2/5] Starting MinIO... tasklist /fi "imagename eq minio.exe" | find /i "minio.exe" >nul if errorlevel 1 ( if not exist "%MINIO_DATA%" mkdir "%MINIO_DATA%" @@ -45,7 +34,7 @@ if errorlevel 1 ( ) echo. -echo [4/6] Starting Backend API... +echo [3/5] Starting Backend API... cd /d "%~dp0backend" start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development echo Backend API starting (http://localhost:5000) @@ -54,12 +43,12 @@ echo Waiting 15s for backend to boot... timeout /t 15 /nobreak >nul echo. -echo [5/6] Starting Patient Frontend... +echo [4/5] Starting Patient Frontend... start "Patient Frontend" cmd.exe /c "cd /d %~dp0frontend-patient && npm run dev" echo Patient Frontend starting on http://localhost:5173 echo. -echo [6/6] Starting Doctor Frontend... +echo [5/5] Starting Doctor Frontend... start "Doctor Frontend" cmd.exe /c "cd /d %~dp0frontend-doctor && npm run dev" echo Doctor Frontend starting on http://localhost:5174 @@ -73,10 +62,9 @@ echo Backend API: http://localhost:5000 echo Swagger: http://localhost:5000/swagger echo MinIO: http://localhost:9001 echo PostgreSQL: localhost:5432 -echo Redis: localhost:6379 echo ========================================== echo. -echo All 6 services started. Close the 3 new +echo All 5 services started. Close the 3 new echo windows to stop the apps. echo. pause diff --git a/上线规划文档.md b/上线规划文档.md index 051228f..e4e85fa 100644 --- a/上线规划文档.md +++ b/上线规划文档.md @@ -234,7 +234,6 @@ GET /api/health-records/export?format=pdf 需要安装的东西和本地一样: - PostgreSQL 18 -- Redis - MinIO - .NET 10 Runtime - Nginx(作为反代和静态文件服务) @@ -417,8 +416,8 @@ POST /api/auth/send-sms → 调短信平台API → 用户手机收到验证码 #### 8.2 健康检查 ``` -GET /health → 检查数据库连接、Redis连接、MinIO连接 -返回 { status: "healthy", db: "ok", redis: "ok", minio: "ok" } +GET /health → 检查数据库连接、MinIO连接 +返回 { status: "healthy", db: "ok", minio: "ok" } ``` 自动化监控:每 30 秒检查一次,挂了自动发短信/邮件报警。 diff --git a/操作手册.md b/操作手册.md index 1fa5d21..6adeb09 100644 --- a/操作手册.md +++ b/操作手册.md @@ -34,16 +34,13 @@ D:\APP\start-dev.bat HealthManager 开发环境启动 ========================================== -[1/4] 启动 PostgreSQL... +[1/3] 启动 PostgreSQL... PostgreSQL 已启动 -[2/4] 启动 Redis... - Redis 已启动 - -[3/4] 启动 MinIO... +[2/3] 启动 MinIO... MinIO 已启动 -[4/4] 启动后端 API... +[3/3] 启动后端 API... 后端 API 启动中 (http://localhost:5000) Swagger: http://localhost:5000/swagger @@ -54,7 +51,6 @@ D:\APP\start-dev.bat Swagger: http://localhost:5000/swagger MinIO: http://localhost:9001 PostgreSQL: localhost:5432 - Redis: localhost:6379 =========================================== ``` @@ -105,9 +101,9 @@ VITE v8.x.x ready in xxx ms ### 1.2 关闭系统 1. 关闭三个命令行窗口(后端、患者前端、医生前端) -2. PostgreSQL、Redis、MinIO 会继续在后台运行。如果想关闭它们: +2. PostgreSQL、MinIO 会继续在后台运行。如果想关闭它们: - 打开任务管理器(Ctrl+Shift+Esc) - - 找到 `postgres.exe`、`redis-server.exe`、`minio.exe` 进程 + - 找到 `postgres.exe`、`minio.exe` 进程 - 分别结束任务 --- @@ -861,8 +857,6 @@ VITE v8.x.x ready in xxx ms | 医生前端 | http://localhost:5174 | | MinIO 控制台 | http://localhost:9001 | | PostgreSQL | localhost:5432 | -| Redis | localhost:6379 | - ### 5.3 常见问题 **Q:登录失败怎么办?**