feat: replace Redis with PostgreSQL for caching, rate limiting, SMS codes, and token blacklist

- Add 4 PG entities: VerificationCode, RateLimitEntry, TokenBlacklistEntry, CacheEntry
- Add 4 services: VerificationService, RateLimitService, TokenBlacklistService, CacheService
- Add CleanupBackgroundService for periodic expired data cleanup
- Add MigrationHelper for safe schema migration without data loss
- Update AuthController: real SMS code generation, rate limiting, logout endpoint with JWT blacklist
- Update JwtProvider: add JTI claim for token revocation
- Update Program.cs: register new services, JWT blacklist validation, DB migration
- Remove StackExchange.Redis NuGet package and all Redis config references
- Update start-dev.bat: 6→5 services, remove Redis startup
- Update docs: remove Redis references from all documentation
- Fix: logout button spacing on profile page
- Fix: .gitignore data/→/data/ to not ignore Infrastructure/Data/
This commit is contained in:
MingNian
2026-05-26 13:48:53 +08:00
parent 39ab6062b5
commit d5f167167a
25 changed files with 613 additions and 47 deletions

View File

@@ -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)
{