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

2
.gitignore vendored
View File

@@ -7,7 +7,7 @@ bin/
obj/ obj/
# Data (large files) # Data (large files)
data/ /data/
# Environment # Environment
.env .env

View File

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

View File

@@ -61,7 +61,6 @@ public class FollowUpService(AppDbContext db)
if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc); if (scheduledAt.HasValue) followUp.ScheduledAt = DateTime.SpecifyKind(scheduledAt.Value, DateTimeKind.Utc);
if (status != null) followUp.Status = status; if (status != null) followUp.Status = status;
if (notes != null) followUp.Notes = notes; if (notes != null) followUp.Notes = notes;
followUp.DoctorId = doctorId;
followUp.UpdatedAt = DateTime.UtcNow; followUp.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -8,7 +8,6 @@
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" /> <PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <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" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup> </ItemGroup>

View File

@@ -19,6 +19,7 @@ public class JwtProvider(IConfiguration configuration) : IJwtProvider
new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Name, name), new Claim(ClaimTypes.Name, name),
new Claim(ClaimTypes.Role, role), new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var token = new JwtSecurityToken( var token = new JwtSecurityToken(

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using HealthManager.Application.DTOs.Auth; using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces; using HealthManager.Domain.Interfaces;
@@ -13,25 +14,34 @@ namespace HealthManager.WebApi.Controllers;
[Route("api/auth")] [Route("api/auth")]
public class AuthController( public class AuthController(
AuthService authService, AuthService authService,
IJwtProvider jwtProvider) : ControllerBase IJwtProvider jwtProvider,
VerificationService verificationService,
RateLimitService rateLimit,
TokenBlacklistService tokenBlacklist) : ControllerBase
{ {
[HttpPost("send-sms")] [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 = "验证码已发送" }); return Ok(new { message = "验证码已发送" });
} }
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request) public async Task<IActionResult> Login([FromBody] LoginRequest request)
{ {
// Demo: skip SMS verification, accept any code
var user = await authService.GetUserByPhoneAsync(request.Phone); var user = await authService.GetUserByPhoneAsync(request.Phone);
if (user == null) if (user == null)
{ {
// Demo: auto-register new users
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>(); 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() var deleted = await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted); .FirstOrDefaultAsync(u => u.Phone == request.Phone && u.IsDeleted);
if (deleted != null) if (deleted != null)
@@ -77,7 +87,6 @@ public class AuthController(
PasswordHash = AuthService.HashPassword("demo123"), PasswordHash = AuthService.HashPassword("demo123"),
}; };
// Access DbContext via DI
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>(); var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user); db.Users.Add(user);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -89,6 +98,24 @@ public class AuthController(
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken)); 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")] [HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request) public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{ {

View File

@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.OpenApi" Version="2.4.1" /> <PackageReference Include="Microsoft.OpenApi" Version="2.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup> </ItemGroup>

View File

@@ -1,9 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text; using System.Text;
using HealthManager.Domain.Interfaces; using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services; using HealthManager.Application.Services;
using HealthManager.Infrastructure.Data; using HealthManager.Infrastructure.Data;
using HealthManager.Infrastructure.Services; using HealthManager.Infrastructure.Services;
using HealthManager.WebApi.Hubs; using HealthManager.WebApi.Hubs;
using HealthManager.WebApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -39,6 +41,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken; context.Token = accessToken;
return Task.CompletedTask; 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<PatientService>();
builder.Services.AddScoped<NotificationService>(); 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 // SignalR
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -99,6 +116,7 @@ using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await MigrationHelper.EnsureNewTablesAsync(db);
await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db);
} }

View File

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

View File

@@ -14,9 +14,6 @@
"Issuer": "HealthManager", "Issuer": "HealthManager",
"Audience": "HealthManagerApp" "Audience": "HealthManagerApp"
}, },
"Redis": {
"Connection": "localhost:6379"
},
"MinIO": { "MinIO": {
"Endpoint": "localhost:9000", "Endpoint": "localhost:9000",
"AccessKey": "minioadmin", "AccessKey": "minioadmin",

View File

@@ -43,7 +43,6 @@
| **SignalR** | 微软的实时通信框架 | 实现医生和患者之间的实时聊天 | | **SignalR** | 微软的实时通信框架 | 实现医生和患者之间的实时聊天 |
| **Swagger** | API 文档工具 | 自动生成 API 文档页面,可以直接在浏览器里测试接口 | | **Swagger** | API 文档工具 | 自动生成 API 文档页面,可以直接在浏览器里测试接口 |
| **MinIO** | S3 兼容的对象存储 | 存储图片(报告照片、头像等) | | **MinIO** | S3 兼容的对象存储 | 存储图片(报告照片、头像等) |
| **Redis** | 内存缓存数据库 | 缓存常用数据,加速访问 |
### 1.3 项目文件结构 ### 1.3 项目文件结构
@@ -385,7 +384,7 @@ DTO = Data Transfer Object。用于前后端之间传输数据而不是直接
| 文件 | 内容 | | 文件 | 内容 |
|------|------| |------|------|
| `appsettings.json` | PostgreSQL 连接串JWT 密钥/签发者Redis 连接MinIO 连接 | | `appsettings.json` | PostgreSQL 连接串JWT 密钥/签发者MinIO 连接 |
| `appsettings.Development.json` | 开发环境覆盖配置 | | `appsettings.Development.json` | 开发环境覆盖配置 |
| `Properties/launchSettings.json` | 启动配置端口 5000Development 环境自动开 Swagger | | `Properties/launchSettings.json` | 启动配置端口 5000Development 环境自动开 Swagger |

View File

@@ -126,7 +126,7 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
margin-top: 4px; margin-top: 24px;
} }
.logoutBtn:active { background: var(--color-danger-bg); } .logoutBtn:active { background: var(--color-danger-bg); }

View File

@@ -6,12 +6,11 @@ echo ==========================================
echo HealthManager Dev Environment echo HealthManager Dev Environment
echo ========================================== echo ==========================================
set "REDIS=C:\Program Files\Redis\redis-server.exe"
set "PG_DATA=D:\APP\data\pgdata" set "PG_DATA=D:\APP\data\pgdata"
set "PG_BIN=D:\PostgreSQL\18\pgsql\bin" set "PG_BIN=D:\PostgreSQL\18\pgsql\bin"
set "MINIO_DATA=D:\APP\data\minio" set "MINIO_DATA=D:\APP\data\minio"
echo. echo.
echo [1/6] Starting PostgreSQL... echo [1/5] Starting PostgreSQL...
if exist "%PG_BIN%\pg_ctl.exe" ( if exist "%PG_BIN%\pg_ctl.exe" (
"%PG_BIN%\pg_ctl.exe" -D "%PG_DATA%" -l "%PG_DATA%\pg.log" start 2>nul "%PG_BIN%\pg_ctl.exe" -D "%PG_DATA%" -l "%PG_DATA%\pg.log" start 2>nul
if errorlevel 1 ( if errorlevel 1 (
@@ -24,17 +23,7 @@ if exist "%PG_BIN%\pg_ctl.exe" (
) )
echo. echo.
echo [2/6] Starting Redis... echo [2/5] Starting MinIO...
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...
tasklist /fi "imagename eq minio.exe" | find /i "minio.exe" >nul tasklist /fi "imagename eq minio.exe" | find /i "minio.exe" >nul
if errorlevel 1 ( if errorlevel 1 (
if not exist "%MINIO_DATA%" mkdir "%MINIO_DATA%" if not exist "%MINIO_DATA%" mkdir "%MINIO_DATA%"
@@ -45,7 +34,7 @@ if errorlevel 1 (
) )
echo. echo.
echo [4/6] Starting Backend API... echo [3/5] Starting Backend API...
cd /d "%~dp0backend" cd /d "%~dp0backend"
start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development start "HealthManager API" dotnet run --project src\HealthManager.WebApi --urls "http://localhost:5000" --environment Development
echo Backend API starting (http://localhost:5000) echo Backend API starting (http://localhost:5000)
@@ -54,12 +43,12 @@ echo Waiting 15s for backend to boot...
timeout /t 15 /nobreak >nul timeout /t 15 /nobreak >nul
echo. 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" start "Patient Frontend" cmd.exe /c "cd /d %~dp0frontend-patient && npm run dev"
echo Patient Frontend starting on http://localhost:5173 echo Patient Frontend starting on http://localhost:5173
echo. 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" start "Doctor Frontend" cmd.exe /c "cd /d %~dp0frontend-doctor && npm run dev"
echo Doctor Frontend starting on http://localhost:5174 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 Swagger: http://localhost:5000/swagger
echo MinIO: http://localhost:9001 echo MinIO: http://localhost:9001
echo PostgreSQL: localhost:5432 echo PostgreSQL: localhost:5432
echo Redis: localhost:6379
echo ========================================== echo ==========================================
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 windows to stop the apps.
echo. echo.
pause pause

View File

@@ -234,7 +234,6 @@ GET /api/health-records/export?format=pdf
需要安装的东西和本地一样: 需要安装的东西和本地一样:
- PostgreSQL 18 - PostgreSQL 18
- Redis
- MinIO - MinIO
- .NET 10 Runtime - .NET 10 Runtime
- Nginx作为反代和静态文件服务 - Nginx作为反代和静态文件服务
@@ -417,8 +416,8 @@ POST /api/auth/send-sms → 调短信平台API → 用户手机收到验证码
#### 8.2 健康检查 #### 8.2 健康检查
``` ```
GET /health → 检查数据库连接、Redis连接、MinIO连接 GET /health → 检查数据库连接、MinIO连接
返回 { status: "healthy", db: "ok", redis: "ok", minio: "ok" } 返回 { status: "healthy", db: "ok", minio: "ok" }
``` ```
自动化监控:每 30 秒检查一次,挂了自动发短信/邮件报警。 自动化监控:每 30 秒检查一次,挂了自动发短信/邮件报警。

View File

@@ -34,16 +34,13 @@ D:\APP\start-dev.bat
HealthManager 开发环境启动 HealthManager 开发环境启动
========================================== ==========================================
[1/4] 启动 PostgreSQL... [1/3] 启动 PostgreSQL...
PostgreSQL 已启动 PostgreSQL 已启动
[2/4] 启动 Redis... [2/3] 启动 MinIO...
Redis 已启动
[3/4] 启动 MinIO...
MinIO 已启动 MinIO 已启动
[4/4] 启动后端 API... [3/3] 启动后端 API...
后端 API 启动中 (http://localhost:5000) 后端 API 启动中 (http://localhost:5000)
Swagger: http://localhost:5000/swagger Swagger: http://localhost:5000/swagger
@@ -54,7 +51,6 @@ D:\APP\start-dev.bat
Swagger: http://localhost:5000/swagger Swagger: http://localhost:5000/swagger
MinIO: http://localhost:9001 MinIO: http://localhost:9001
PostgreSQL: localhost:5432 PostgreSQL: localhost:5432
Redis: localhost:6379
=========================================== ===========================================
``` ```
@@ -105,9 +101,9 @@ VITE v8.x.x ready in xxx ms
### 1.2 关闭系统 ### 1.2 关闭系统
1. 关闭三个命令行窗口(后端、患者前端、医生前端) 1. 关闭三个命令行窗口(后端、患者前端、医生前端)
2. PostgreSQL、Redis、MinIO 会继续在后台运行。如果想关闭它们: 2. PostgreSQL、MinIO 会继续在后台运行。如果想关闭它们:
- 打开任务管理器Ctrl+Shift+Esc - 打开任务管理器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 | | 医生前端 | http://localhost:5174 |
| MinIO 控制台 | http://localhost:9001 | | MinIO 控制台 | http://localhost:9001 |
| PostgreSQL | localhost:5432 | | PostgreSQL | localhost:5432 |
| Redis | localhost:6379 |
### 5.3 常见问题 ### 5.3 常见问题
**Q登录失败怎么办** **Q登录失败怎么办**