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:
@@ -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<T?> GetAsync<T>(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<T>(entry.Value.RootElement.GetRawText(), JsonOptions);
|
||||
}
|
||||
|
||||
public async Task SetAsync<T>(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<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl) where T : class
|
||||
{
|
||||
var cached = await GetAsync<T>(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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<bool> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<bool> 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();
|
||||
}
|
||||
}
|
||||
12
backend/src/HealthManager.Domain/Entities/CacheEntry.cs
Normal file
12
backend/src/HealthManager.Domain/Entities/CacheEntry.cs
Normal file
@@ -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;
|
||||
}
|
||||
10
backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs
Normal file
10
backend/src/HealthManager.Domain/Entities/RateLimitEntry.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<Infrastructure.Data.AppDbContext>();
|
||||
|
||||
// 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<Infrastructure.Data.AppDbContext>();
|
||||
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<IActionResult> 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<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<TokenBlacklistService>();
|
||||
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<FollowUpService>();
|
||||
builder.Services.AddScoped<PatientService>();
|
||||
builder.Services.AddScoped<NotificationService>();
|
||||
|
||||
// PG-based replacements for Redis
|
||||
builder.Services.AddScoped<VerificationService>();
|
||||
builder.Services.AddScoped<RateLimitService>();
|
||||
builder.Services.AddScoped<TokenBlacklistService>();
|
||||
builder.Services.AddScoped<CacheService>();
|
||||
builder.Services.AddHostedService<CleanupBackgroundService>();
|
||||
|
||||
// SignalR
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
@@ -99,6 +116,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await MigrationHelper.EnsureNewTablesAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VerificationService>();
|
||||
var tokenBlacklist = scope.ServiceProvider.GetRequiredService<TokenBlacklistService>();
|
||||
|
||||
await verification.CleanupExpiredAsync();
|
||||
await tokenBlacklist.CleanupExpiredAsync();
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<Infrastructure.Data.AppDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@
|
||||
"Issuer": "HealthManager",
|
||||
"Audience": "HealthManagerApp"
|
||||
},
|
||||
"Redis": {
|
||||
"Connection": "localhost:6379"
|
||||
},
|
||||
"MinIO": {
|
||||
"Endpoint": "localhost:9000",
|
||||
"AccessKey": "minioadmin",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user