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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
|
|
||||||
# Data (large files)
|
# Data (large files)
|
||||||
data/
|
/data/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -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 (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();
|
||||||
|
|||||||
@@ -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="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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"Issuer": "HealthManager",
|
||||||
"Audience": "HealthManagerApp"
|
"Audience": "HealthManagerApp"
|
||||||
},
|
},
|
||||||
"Redis": {
|
|
||||||
"Connection": "localhost:6379"
|
|
||||||
},
|
|
||||||
"MinIO": {
|
"MinIO": {
|
||||||
"Endpoint": "localhost:9000",
|
"Endpoint": "localhost:9000",
|
||||||
"AccessKey": "minioadmin",
|
"AccessKey": "minioadmin",
|
||||||
|
|||||||
@@ -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` | 启动配置:端口 5000,Development 环境,自动开 Swagger |
|
| `Properties/launchSettings.json` | 启动配置:端口 5000,Development 环境,自动开 Swagger |
|
||||||
|
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 秒检查一次,挂了自动发短信/邮件报警。
|
||||||
|
|||||||
16
操作手册.md
16
操作手册.md
@@ -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:登录失败怎么办?**
|
||||||
|
|||||||
Reference in New Issue
Block a user