Initial commit: HealthManager full-stack health management platform

Backend: .NET 10 + PostgreSQL + EF Core + JWT + SignalR
Frontend patient: React 19 + TypeScript + Vite (mobile H5)
Frontend doctor: React 19 + TypeScript + Vite (desktop web)
This commit is contained in:
MingNian
2026-05-20 16:18:56 +08:00
commit 435af55c4a
215 changed files with 18595 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/HealthManager.Application/HealthManager.Application.csproj" />
<Project Path="src/HealthManager.Domain/HealthManager.Domain.csproj" />
<Project Path="src/HealthManager.Infrastructure/HealthManager.Infrastructure.csproj" />
<Project Path="src/HealthManager.WebApi/HealthManager.WebApi.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,19 @@
namespace HealthManager.Application.DTOs.Auth;
public record LoginRequest(string Phone, string SmsCode);
public record RegisterRequest(string Phone, string SmsCode, string Name);
public record SendSmsRequest(string Phone);
public record AuthResponse(Guid UserId, string Name, string Role, string AccessToken, string RefreshToken);
public record UserProfileResponse(
Guid Id, string Name, string Phone, string Role,
string? Gender, DateOnly? Birthday, decimal? HeightCm, decimal? WeightKg,
List<string>? MedicalHistory, DateOnly? StentDate, string? StentType,
string? Department, string? Title, List<string>? Specialty, string? Introduction);
public record UpdateProfileRequest(
string? Name, string? Gender, DateOnly? Birthday,
decimal? HeightCm, decimal? WeightKg, List<string>? MedicalHistory);

View File

@@ -0,0 +1,16 @@
namespace HealthManager.Application.DTOs.Consultation;
public record ConsultationCreateRequest(Guid DoctorId, string Subject);
public record ConsultationResponse(
Guid Id, Guid PatientId, Guid DoctorId,
string PatientName, string DoctorName,
string? Subject, string Status, DateTime StartedAt,
DateTime? ClosedAt, string? Summary);
public record SendMessageRequest(string Content, string ContentType = "text", string? ImageUrl = null);
public record MessageResponse(
Guid Id, Guid SenderId, string SenderRole,
string ContentType, string Content,
string? ImageUrl, bool IsRead, DateTime CreatedAt);

View File

@@ -0,0 +1,12 @@
namespace HealthManager.Application.DTOs.FollowUp;
public record FollowUpCreateRequest(
string Title, string? Description, DateTime ScheduledAt, bool ReminderEnabled = true);
public record FollowUpUpdateRequest(
string? Title, string? Description, DateTime? ScheduledAt, string? Status, string? Notes);
public record FollowUpResponse(
Guid Id, Guid PatientId, string PatientName, Guid? DoctorId, string? DoctorName,
string Title, string? Description, DateTime ScheduledAt, string Status,
string? Notes, bool ReminderEnabled, DateTime CreatedAt);

View File

@@ -0,0 +1,14 @@
namespace HealthManager.Application.DTOs.Health;
public record HealthRecordCreateRequest(
string Type, string ValueJson, string Unit, DateTime RecordedAt, string? Notes);
public record HealthRecordResponse(
Guid Id, string Type, string ValueJson, string Unit,
DateTime RecordedAt, string Source, string? Notes, DateTime CreatedAt);
public record HealthStatsResponse(
string Type, string Unit,
HealthRecordResponse? Latest,
decimal? WeekAvg, decimal? MonthAvg,
string Trend); // up, down, stable

View File

@@ -0,0 +1,18 @@
namespace HealthManager.Application.DTOs.Medication;
public record MedicationCreateRequest(
string DrugName, string Dosage, string Frequency,
List<string> TimeSlots, DateOnly StartDate, DateOnly? EndDate, string? Notes);
public record MedicationResponse(
Guid Id, Guid UserId, string DrugName, string Dosage,
string Frequency, List<string> TimeSlots,
DateOnly StartDate, DateOnly? EndDate, string? Notes,
string Status, DateTime CreatedAt);
public record MedicationRecordResponse(
Guid Id, Guid MedicationId, string TimeSlot, DateTime? TakenAt,
bool IsTaken, string? SkippedReason, DateTime CreatedAt);
public record AdherenceResponse(
Guid MedicationId, string DrugName, decimal Rate, int TakenDays, int TotalDays);

View File

@@ -0,0 +1,15 @@
namespace HealthManager.Application.DTOs.Report;
public record ReportUploadRequest(string Title, string Category, List<string> ImageUrls);
public record ReportInterpretRequest(string Summary, List<ReportItemDto> Items, string RiskLevel, string? Suggestions);
public record ReportResponse(
Guid Id, Guid PatientId, string PatientName, string? DoctorName,
string Title, string Category, List<string> ImageUrls,
string Status, string? RiskLevel, string? Summary, string? Suggestions,
List<ReportItemDto> Items, DateTime UploadedAt, DateTime? CompletedAt);
public record ReportItemDto(
string ItemName, string ResultValue, string? Unit,
string? ReferenceRange, bool IsAbnormal);

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\HealthManager.Domain\HealthManager.Domain.csproj" />
<ProjectReference Include="..\HealthManager.Infrastructure\HealthManager.Infrastructure.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class AuthService(AppDbContext db)
{
public async Task<User?> GetUserByPhoneAsync(string phone)
{
return await db.Users.FirstOrDefaultAsync(u => u.Phone == phone && !u.IsDeleted);
}
public async Task<RefreshToken?> GetRefreshTokenAsync(string token)
{
return await db.RefreshTokens
.Include(rt => rt.User)
.FirstOrDefaultAsync(rt => rt.Token == token && rt.RevokedAt == null && rt.ExpiresAt > DateTime.UtcNow);
}
public async Task RevokeRefreshTokenAsync(Guid userId)
{
var tokens = await db.RefreshTokens
.Where(rt => rt.UserId == userId && rt.RevokedAt == null)
.ToListAsync();
foreach (var t in tokens)
t.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task SaveRefreshTokenAsync(Guid userId, string token, DateTime expiresAt)
{
db.RefreshTokens.Add(new RefreshToken
{
UserId = userId,
Token = token,
ExpiresAt = expiresAt,
});
await db.SaveChangesAsync();
}
public static string HashPassword(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
return Convert.ToHexStringLower(bytes);
}
public static bool VerifyPassword(string password, string hash)
=> HashPassword(password) == hash;
}

View File

@@ -0,0 +1,73 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class ConsultationService(AppDbContext db)
{
public async Task<List<Consultation>> GetPatientConsultationsAsync(Guid patientId)
=> await db.Consultations
.Include(c => c.Doctor)
.Where(c => c.PatientId == patientId)
.OrderByDescending(c => c.StartedAt)
.ToListAsync();
public async Task<List<Consultation>> GetDoctorConsultationsAsync(Guid doctorId)
=> await db.Consultations
.Include(c => c.Patient)
.Where(c => c.DoctorId == doctorId)
.OrderByDescending(c => c.StartedAt)
.ToListAsync();
public async Task<Consultation?> GetByIdAsync(Guid id)
=> await db.Consultations
.Include(c => c.Patient)
.Include(c => c.Doctor)
.FirstOrDefaultAsync(c => c.Id == id);
public async Task<Consultation> StartAsync(Guid patientId, Guid doctorId, string subject)
{
var consultation = new Consultation
{
PatientId = patientId,
DoctorId = doctorId,
Subject = subject,
};
db.Consultations.Add(consultation);
await db.SaveChangesAsync();
return consultation;
}
public async Task<List<ConsultationMessage>> GetMessagesAsync(Guid consultationId)
=> await db.ConsultationMessages
.Include(m => m.Sender)
.Where(m => m.ConsultationId == consultationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync();
public async Task<ConsultationMessage> SendMessageAsync(Guid consultationId, Guid senderId, string senderRole,
string content, string contentType = "text", string? imageUrl = null)
{
var message = new ConsultationMessage
{
ConsultationId = consultationId,
SenderId = senderId,
SenderRole = senderRole,
Content = content,
ContentType = contentType,
ImageUrl = imageUrl,
};
db.ConsultationMessages.Add(message);
await db.SaveChangesAsync();
return message;
}
public async Task<List<User>> GetDoctorsAsync(string? department = null)
{
var query = db.Users.Where(u => u.Role == "doctor" && u.IsAvailable && !u.IsDeleted);
if (!string.IsNullOrEmpty(department))
query = query.Where(u => u.Department == department);
return await query.ToListAsync();
}
}

View File

@@ -0,0 +1,62 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class FollowUpService(AppDbContext db)
{
public async Task<List<FollowUp>> GetPatientFollowUpsAsync(Guid patientId)
=> await db.FollowUps
.Include(f => f.Doctor)
.Where(f => f.PatientId == patientId)
.OrderBy(f => f.ScheduledAt)
.ToListAsync();
public async Task<List<FollowUp>> GetDoctorFollowUpsAsync(Guid doctorId)
=> await db.FollowUps
.Include(f => f.Patient)
.Where(f => f.DoctorId == doctorId)
.OrderBy(f => f.ScheduledAt)
.ToListAsync();
public async Task<FollowUp> AddAsync(Guid patientId, string title, string? description, DateTime scheduledAt, bool reminderEnabled, Guid? doctorId = null)
{
var followUp = new FollowUp
{
PatientId = patientId,
DoctorId = doctorId,
Title = title,
Description = description,
ScheduledAt = DateTime.SpecifyKind(scheduledAt, DateTimeKind.Utc),
ReminderEnabled = reminderEnabled,
};
db.FollowUps.Add(followUp);
await db.SaveChangesAsync();
return followUp;
}
public async Task<FollowUp?> GetByIdAsync(Guid id)
=> await db.FollowUps
.Include(f => f.Patient)
.Include(f => f.Doctor)
.FirstOrDefaultAsync(f => f.Id == id);
public async Task<FollowUp?> UpdateAsync(Guid id, Guid doctorId, string? title, string? description,
DateTime? scheduledAt, string? status, string? notes)
{
var followUp = await db.FollowUps.FindAsync(id);
if (followUp == null) return null;
if (title != null) followUp.Title = title;
if (description != null) followUp.Description = description;
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();
return followUp;
}
}

View File

@@ -0,0 +1,114 @@
using System.Text.Json;
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class HealthService(AppDbContext db)
{
public async Task<List<HealthRecord>> GetRecordsAsync(Guid userId, string? type, int days = 30)
{
var cutoff = DateTime.UtcNow.AddDays(-days);
var query = db.HealthRecords.Where(hr => hr.UserId == userId && hr.RecordedAt >= cutoff);
if (!string.IsNullOrEmpty(type))
query = query.Where(hr => hr.Type == type);
return await query.OrderByDescending(hr => hr.RecordedAt).ToListAsync();
}
public async Task<HealthRecord?> GetLatestAsync(Guid userId, string type)
{
return await db.HealthRecords
.Where(hr => hr.UserId == userId && hr.Type == type)
.OrderByDescending(hr => hr.RecordedAt)
.FirstOrDefaultAsync();
}
public async Task<HealthRecord> AddRecordAsync(Guid userId, string type, string valueJson, string unit, DateTime recordedAt, string? notes)
{
var record = new HealthRecord
{
UserId = userId,
Type = type,
Value = JsonDocument.Parse(valueJson),
Unit = unit,
RecordedAt = DateTime.SpecifyKind(recordedAt, DateTimeKind.Utc),
Notes = notes,
Source = "manual",
};
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
return record;
}
public async Task<Dictionary<string, object>> GetStatsAsync(Guid userId)
{
var types = new[] { "blood_pressure", "heart_rate", "blood_sugar", "spo2", "weight", "steps" };
var result = new Dictionary<string, object>();
foreach (var type in types)
{
var latest = await GetLatestAsync(userId, type);
var records = await db.HealthRecords
.Where(hr => hr.UserId == userId && hr.Type == type)
.OrderByDescending(hr => hr.RecordedAt)
.ToListAsync();
// Calculate trend from last 7 days vs previous 7 days
var now = DateTime.UtcNow;
var weekAgo = now.AddDays(-7);
var twoWeeksAgo = now.AddDays(-14);
var recentValues = records.Where(r => r.RecordedAt >= weekAgo).ToList();
var olderValues = records.Where(r => r.RecordedAt >= twoWeeksAgo && r.RecordedAt < weekAgo).ToList();
var recentAvg = CalculateAvg(recentValues);
var olderAvg = CalculateAvg(olderValues);
string trend = "stable";
if (olderAvg > 0 && recentAvg > 0)
{
if (recentAvg > olderAvg * 1.03m) trend = "up";
else if (recentAvg < olderAvg * 0.97m) trend = "down";
}
result[type] = new
{
Latest = latest,
WeekAvg = recentAvg > 0 ? recentAvg : (decimal?)null,
MonthAvg = records.Count > 0 ? CalculateAvg(records) : (decimal?)null,
Trend = trend,
};
}
return result;
}
private static decimal CalculateAvg(List<HealthRecord> records)
{
if (records.Count == 0) return 0;
var sum = 0m;
var count = 0;
foreach (var r in records)
{
// Blood pressure: { systolic, diastolic, pulse }
if (r.Value.RootElement.TryGetProperty("systolic", out var sys) &&
r.Value.RootElement.TryGetProperty("diastolic", out var dia))
{
sum += sys.GetDecimal(); // Use systolic for trend
count++;
}
// All other types: { value } — heart_rate, blood_sugar, spo2, weight, steps
else if (r.Value.RootElement.TryGetProperty("value", out var val))
{
sum += val.GetDecimal();
count++;
}
}
return count > 0 ? sum / count : 0;
}
}

View File

@@ -0,0 +1,93 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class MedicationService(AppDbContext db)
{
public async Task<List<Medication>> GetUserMedicationsAsync(Guid userId)
=> await db.Medications
.Where(m => m.UserId == userId)
.OrderByDescending(m => m.CreatedAt)
.ToListAsync();
public async Task<Medication?> GetByIdAsync(Guid id)
=> await db.Medications.FindAsync(id);
public async Task<Medication> AddAsync(Guid userId, string drugName, string dosage, string frequency,
List<string> timeSlots, DateOnly startDate, DateOnly? endDate, string? notes, Guid? doctorId = null)
{
var med = new Medication
{
UserId = userId,
DoctorId = doctorId,
DrugName = drugName,
Dosage = dosage,
Frequency = frequency,
TimeSlots = timeSlots,
StartDate = startDate,
EndDate = endDate,
Notes = notes,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
return med;
}
public async Task<List<MedicationRecord>> GetRecordsAsync(Guid medicationId)
=> await db.MedicationRecords
.Where(mr => mr.MedicationId == medicationId)
.OrderByDescending(mr => mr.CreatedAt)
.ToListAsync();
public async Task<MedicationRecord> MarkTakenAsync(Guid medicationId, Guid userId, string timeSlot)
{
// Find existing record for today's slot, update it instead of creating duplicate
var today = DateTime.UtcNow.Date;
var existing = await db.MedicationRecords
.FirstOrDefaultAsync(mr =>
mr.MedicationId == medicationId &&
mr.UserId == userId &&
mr.TimeSlot == timeSlot &&
mr.TakenAt.HasValue &&
mr.TakenAt.Value.Date == today);
if (existing != null)
{
existing.TakenAt = DateTime.UtcNow;
existing.IsTaken = true;
existing.SkippedReason = null;
await db.SaveChangesAsync();
return existing;
}
var record = new MedicationRecord
{
MedicationId = medicationId,
UserId = userId,
TimeSlot = timeSlot,
IsTaken = true,
TakenAt = DateTime.UtcNow,
};
db.MedicationRecords.Add(record);
await db.SaveChangesAsync();
return record;
}
public async Task<decimal> GetAdherenceRateAsync(Guid medicationId, int days = 30)
{
var medication = await db.Medications.FindAsync(medicationId);
if (medication == null) return 0;
var cutoff = DateTime.UtcNow.AddDays(-days);
var allRecords = await db.MedicationRecords
.Where(mr => mr.MedicationId == medicationId && mr.CreatedAt >= cutoff)
.ToListAsync();
var takenCount = allRecords.Count(mr => mr.IsTaken);
var totalCount = allRecords.Count;
return totalCount > 0 ? Math.Round((decimal)takenCount / totalCount * 100, 1) : 0;
}
}

View File

@@ -0,0 +1,52 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class NotificationService(AppDbContext db)
{
public async Task<List<Notification>> GetUserNotificationsAsync(Guid userId)
=> await db.Notifications
.Where(n => n.UserId == userId)
.OrderByDescending(n => n.CreatedAt)
.ToListAsync();
public async Task<int> GetUnreadCountAsync(Guid userId)
=> await db.Notifications.CountAsync(n => n.UserId == userId && !n.IsRead);
public async Task MarkAsReadAsync(Guid notificationId)
{
var notification = await db.Notifications.FindAsync(notificationId);
if (notification != null && !notification.IsRead)
{
notification.IsRead = true;
notification.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
public async Task MarkAllAsReadAsync(Guid userId)
{
var unread = await db.Notifications.Where(n => n.UserId == userId && !n.IsRead).ToListAsync();
foreach (var n in unread)
{
n.IsRead = true;
n.ReadAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
}
public async Task CreateAsync(Guid userId, string type, string title, string content, Guid? relatedId = null)
{
db.Notifications.Add(new Notification
{
UserId = userId,
Type = type,
Title = title,
Content = content,
RelatedId = relatedId,
});
await db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,43 @@
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class PatientService(AppDbContext db)
{
public async Task<List<object>> GetPatientsAsync(string? search, string? department, int page = 1, int pageSize = 20)
{
var query = db.Users.Where(u => u.Role == "patient" && !u.IsDeleted);
if (!string.IsNullOrEmpty(search))
query = query.Where(u => u.Name.Contains(search) || u.Phone.Contains(search));
var patients = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new
{
u.Id, u.Name, u.Phone, u.Gender, u.Birthday,
u.StentDate, u.StentType,
u.CreatedAt,
})
.ToListAsync();
return patients.Cast<object>().ToList();
}
public async Task<object?> GetPatientDetailAsync(Guid patientId)
{
return await db.Users
.Where(u => u.Id == patientId && u.Role == "patient" && !u.IsDeleted)
.Select(u => new
{
u.Id, u.Name, u.Phone, u.Gender, u.Birthday,
u.HeightCm, u.WeightKg, u.MedicalHistory,
u.StentDate, u.StentType,
u.CreatedAt,
})
.FirstOrDefaultAsync();
}
}

View File

@@ -0,0 +1,76 @@
using HealthManager.Domain.Entities;
using HealthManager.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace HealthManager.Application.Services;
public class ReportService(AppDbContext db)
{
public async Task<List<Report>> GetPatientReportsAsync(Guid patientId)
=> await db.Reports
.Include(r => r.Doctor)
.Include(r => r.Items)
.Where(r => r.PatientId == patientId)
.OrderByDescending(r => r.UploadedAt)
.ToListAsync();
public async Task<Report?> GetByIdAsync(Guid id)
=> await db.Reports
.Include(r => r.Patient)
.Include(r => r.Doctor)
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == id);
public async Task<Report> UploadAsync(Guid patientId, string title, string category, List<string> imageUrls)
{
var report = new Report
{
PatientId = patientId,
Title = title,
Category = category,
ImageUrls = imageUrls,
UploadedBy = patientId,
};
db.Reports.Add(report);
await db.SaveChangesAsync();
return report;
}
public async Task<List<Report>> GetPendingAsync()
=> await db.Reports
.Include(r => r.Patient)
.Where(r => r.Status == "pending")
.OrderByDescending(r => r.UploadedAt)
.ToListAsync();
public async Task<Report> InterpretAsync(Guid reportId, Guid doctorId, string summary,
List<(string name, string value, string? unit, string? range, bool abnormal)> items,
string riskLevel, string? suggestions)
{
var report = await db.Reports.FindAsync(reportId) ?? throw new InvalidOperationException("Report not found");
report.DoctorId = doctorId;
report.Status = "completed";
report.Summary = summary;
report.RiskLevel = riskLevel;
report.Suggestions = suggestions;
report.CompletedAt = DateTime.UtcNow;
var order = 0;
foreach (var (name, value, unit, range, abnormal) in items)
{
db.ReportItems.Add(new ReportItem
{
ReportId = reportId,
ItemName = name,
ResultValue = value,
Unit = unit,
ReferenceRange = range,
IsAbnormal = abnormal,
SortOrder = order++,
});
}
await db.SaveChangesAsync();
return report;
}
}

View File

@@ -0,0 +1,19 @@
namespace HealthManager.Domain.Entities;
public class Consultation
{
public Guid Id { get; set; }
public Guid PatientId { get; set; }
public Guid DoctorId { get; set; }
public string? Subject { get; set; }
public string Status { get; set; } = "active"; // active, closed
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public Guid? ClosedBy { get; set; }
public string? Summary { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User Patient { get; set; } = null!;
public User Doctor { get; set; } = null!;
public ICollection<ConsultationMessage> Messages { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace HealthManager.Domain.Entities;
public class ConsultationMessage
{
public Guid Id { get; set; }
public Guid ConsultationId { get; set; }
public Guid SenderId { get; set; }
public string SenderRole { get; set; } = string.Empty; // patient, doctor, system
public string ContentType { get; set; } = "text"; // text, image, file, template
public string Content { get; set; } = string.Empty;
public string? ImageUrl { get; set; }
public string? FileUrl { get; set; }
public bool IsRead { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Consultation Consultation { get; set; } = null!;
public User Sender { get; set; } = null!;
}

View File

@@ -0,0 +1,18 @@
namespace HealthManager.Domain.Entities;
public class Device
{
public Guid Id { get; set; }
public Guid? UserId { get; set; }
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string? MacAddress { get; set; }
public string? SerialNumber { get; set; }
public bool IsBound { get; set; }
public DateTime? BoundAt { get; set; }
public DateTime? LastSyncAt { get; set; }
public int? BatteryLevel { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User? User { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace HealthManager.Domain.Entities;
public class DietRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string MealType { get; set; } = string.Empty; // breakfast, lunch, dinner, snack
public string FoodName { get; set; } = string.Empty;
public string? Portion { get; set; }
public int? Calories { get; set; }
public string? Notes { get; set; }
public DateOnly RecordedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,16 @@
namespace HealthManager.Domain.Entities;
public class ExerciseRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string ExerciseType { get; set; } = string.Empty;
public int DurationMin { get; set; }
public string? Intensity { get; set; } // low, moderate, high
public int? CaloriesBurned { get; set; }
public string? Notes { get; set; }
public DateOnly RecordedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,19 @@
namespace HealthManager.Domain.Entities;
public class FollowUp
{
public Guid Id { get; set; }
public Guid PatientId { get; set; }
public Guid? DoctorId { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime ScheduledAt { get; set; }
public string Status { get; set; } = "upcoming"; // upcoming, completed, cancelled, rescheduled
public string? Notes { get; set; }
public bool ReminderEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User Patient { get; set; } = null!;
public User? Doctor { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json;
namespace HealthManager.Domain.Entities;
public class HealthRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Type { get; set; } = string.Empty; // blood_pressure, heart_rate, blood_sugar, spo2, weight, steps
public JsonDocument Value { get; set; } = null!;
public string Unit { get; set; } = string.Empty;
public DateTime RecordedAt { get; set; }
public string Source { get; set; } = "manual"; // manual, device, doctor
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,22 @@
namespace HealthManager.Domain.Entities;
public class Medication
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? DoctorId { get; set; }
public string DrugName { get; set; } = string.Empty;
public string Dosage { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public List<string> TimeSlots { get; set; } = [];
public DateOnly StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public string? Notes { get; set; }
public string Status { get; set; } = "active"; // active, completed, stopped
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public User? Doctor { get; set; }
public ICollection<MedicationRecord> Records { get; set; } = [];
}

View File

@@ -0,0 +1,16 @@
namespace HealthManager.Domain.Entities;
public class MedicationRecord
{
public Guid Id { get; set; }
public Guid MedicationId { get; set; }
public Guid UserId { get; set; }
public string TimeSlot { get; set; } = string.Empty;
public DateTime? TakenAt { get; set; }
public bool IsTaken { get; set; }
public string? SkippedReason { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Medication Medication { get; set; } = null!;
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,16 @@
namespace HealthManager.Domain.Entities;
public class Notification
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Type { get; set; } = string.Empty; // medication_reminder, followup_reminder, consultation_new, report_completed, health_alert, system
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public Guid? RelatedId { get; set; }
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,14 @@
namespace HealthManager.Domain.Entities;
public class QuickReplyTemplate
{
public Guid Id { get; set; }
public Guid DoctorId { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string? Category { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User Doctor { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace HealthManager.Domain.Entities;
public class RefreshToken
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RevokedAt { get; set; }
public User User { get; set; } = null!;
}

View File

@@ -0,0 +1,22 @@
namespace HealthManager.Domain.Entities;
public class Report
{
public Guid Id { get; set; }
public Guid PatientId { get; set; }
public Guid? DoctorId { get; set; }
public string Title { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public List<string> ImageUrls { get; set; } = [];
public string Status { get; set; } = "pending"; // pending, interpreting, completed
public string? RiskLevel { get; set; } // normal, attention, abnormal
public string? Summary { get; set; }
public string? Suggestions { get; set; }
public Guid UploadedBy { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public User Patient { get; set; } = null!;
public User? Doctor { get; set; }
public ICollection<ReportItem> Items { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace HealthManager.Domain.Entities;
public class ReportItem
{
public Guid Id { get; set; }
public Guid ReportId { get; set; }
public string ItemName { get; set; } = string.Empty;
public string ResultValue { get; set; } = string.Empty;
public string? Unit { get; set; }
public string? ReferenceRange { get; set; }
public bool IsAbnormal { get; set; }
public int SortOrder { get; set; }
public Report Report { get; set; } = null!;
}

View File

@@ -0,0 +1,41 @@
namespace HealthManager.Domain.Entities;
public class User
{
public Guid Id { get; set; }
public string Role { get; set; } = string.Empty; // patient, doctor, admin
public string Phone { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? AvatarUrl { get; set; }
// Patient fields
public string? Gender { get; set; }
public DateOnly? Birthday { get; set; }
public decimal? HeightCm { get; set; }
public decimal? WeightKg { get; set; }
public List<string>? MedicalHistory { get; set; }
public DateOnly? StentDate { get; set; }
public string? StentType { get; set; }
// Doctor fields
public string? Department { get; set; }
public string? Title { get; set; }
public List<string>? Specialty { get; set; }
public string? Introduction { get; set; }
public bool IsAvailable { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
// Navigation
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
public ICollection<HealthRecord> HealthRecords { get; set; } = [];
public ICollection<Medication> Medications { get; set; } = [];
public ICollection<MedicationRecord> MedicationRecords { get; set; } = [];
public ICollection<ExerciseRecord> ExerciseRecords { get; set; } = [];
public ICollection<DietRecord> DietRecords { get; set; } = [];
public ICollection<Notification> Notifications { get; set; } = [];
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,7 @@
namespace HealthManager.Domain.Interfaces;
public interface IJwtProvider
{
string GenerateAccessToken(Guid userId, string name, string role);
string GenerateRefreshToken();
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\HealthManager.Domain\HealthManager.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<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>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using HealthManager.Domain.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace HealthManager.Infrastructure.Services;
public class JwtProvider(IConfiguration configuration) : IJwtProvider
{
private static readonly JwtSecurityTokenHandler TokenHandler = new();
public string GenerateAccessToken(Guid userId, string name, string role)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Secret"]!));
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim(ClaimTypes.Name, name),
new Claim(ClaimTypes.Role, role),
};
var token = new JwtSecurityToken(
issuer: configuration["Jwt:Issuer"],
audience: configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(30),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
return TokenHandler.WriteToken(token);
}
public string GenerateRefreshToken()
{
var bytes = RandomNumberGenerator.GetBytes(64);
return Convert.ToBase64String(bytes);
}
}

View File

@@ -0,0 +1,120 @@
using System.Security.Claims;
using HealthManager.Application.DTOs.Auth;
using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services;
using HealthManager.Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController(
AuthService authService,
IJwtProvider jwtProvider) : ControllerBase
{
[HttpPost("send-sms")]
public IActionResult SendSms([FromBody] SendSmsRequest request)
{
// Demo: always succeed
return Ok(new { message = "验证码已发送" });
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var user = await authService.GetUserByPhoneAsync(request.Phone);
if (user == null)
return Unauthorized(new { message = "用户不存在" });
// Demo: accept any SMS code
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var existing = await authService.GetUserByPhoneAsync(request.Phone);
if (existing != null)
return Conflict(new { message = "该手机号已注册" });
var user = new User
{
Phone = request.Phone,
Name = request.Name,
Role = "patient",
PasswordHash = AuthService.HashPassword("demo123"),
};
// Access DbContext via DI
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
db.Users.Add(user);
await db.SaveChangesAsync();
var accessToken = jwtProvider.GenerateAccessToken(user.Id, user.Name, user.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(user.Id, refreshToken, DateTime.UtcNow.AddDays(7));
return Ok(new AuthResponse(user.Id, user.Name, user.Role, accessToken, refreshToken));
}
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
var saved = await authService.GetRefreshTokenAsync(request.RefreshToken);
if (saved == null)
return Unauthorized(new { message = "无效的刷新令牌" });
await authService.RevokeRefreshTokenAsync(saved.UserId);
var accessToken = jwtProvider.GenerateAccessToken(saved.User.Id, saved.User.Name, saved.User.Role);
var refreshToken = jwtProvider.GenerateRefreshToken();
await authService.SaveRefreshTokenAsync(saved.UserId, refreshToken, DateTime.UtcNow.AddDays(7));
return Ok(new AuthResponse(saved.User.Id, saved.User.Name, saved.User.Role, accessToken, refreshToken));
}
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> GetProfile()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var user = await db.Users.FindAsync(userId);
if (user == null) return NotFound();
return Ok(new UserProfileResponse(
user.Id, user.Name, user.Phone, user.Role,
user.Gender, user.Birthday, user.HeightCm, user.WeightKg,
user.MedicalHistory, user.StentDate, user.StentType,
user.Department, user.Title, user.Specialty, user.Introduction));
}
[HttpPut("me")]
[Authorize]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest request)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var db = HttpContext.RequestServices.GetRequiredService<Infrastructure.Data.AppDbContext>();
var user = await db.Users.FindAsync(userId);
if (user == null) return NotFound();
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.Birthday.HasValue) user.Birthday = request.Birthday;
if (request.HeightCm.HasValue) user.HeightCm = request.HeightCm;
if (request.WeightKg.HasValue) user.WeightKg = request.WeightKg;
if (request.MedicalHistory != null) user.MedicalHistory = request.MedicalHistory;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { message = "更新成功" });
}
}
public record RefreshTokenRequest(string RefreshToken);

View File

@@ -0,0 +1,71 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/consultations")]
[Authorize]
public class ConsultationController(ConsultationService consultationService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet("doctors")]
public async Task<IActionResult> GetDoctors([FromQuery] string? department)
{
var doctors = await consultationService.GetDoctorsAsync(department);
return Ok(doctors.Select(d => new
{
d.Id, d.Name, d.Department, d.Title, d.Specialty, d.Introduction, d.AvatarUrl,
}));
}
[HttpGet]
public async Task<IActionResult> GetConsultations()
{
var consultations = Role == "doctor"
? await consultationService.GetDoctorConsultationsAsync(UserId)
: await consultationService.GetPatientConsultationsAsync(UserId);
return Ok(consultations.Select(c => new
{
c.Id, c.PatientId, c.DoctorId,
PatientName = c.Patient?.Name,
DoctorName = c.Doctor?.Name,
c.Subject, c.Status, c.StartedAt, c.ClosedAt, c.Summary,
}));
}
[HttpPost]
public async Task<IActionResult> StartConsultation([FromBody] StartConsultationRequest request)
{
var consultation = await consultationService.StartAsync(UserId, request.DoctorId, request.Subject);
return Ok(new { consultation.Id, consultation.PatientId, consultation.DoctorId, consultation.Status });
}
[HttpGet("{id:guid}/messages")]
public async Task<IActionResult> GetMessages(Guid id)
{
var messages = await consultationService.GetMessagesAsync(id);
return Ok(messages.Select(m => new
{
m.Id, m.SenderId, m.SenderRole, m.ContentType, m.Content,
m.ImageUrl, m.IsRead, m.CreatedAt,
SenderName = m.Sender?.Name,
}));
}
[HttpPost("{id:guid}/messages")]
public async Task<IActionResult> SendMessage(Guid id, [FromBody] SendMessageRequest request)
{
var message = await consultationService.SendMessageAsync(id, UserId, Role, request.Content,
request.ContentType, request.ImageUrl);
return Ok(new { message.Id, message.SenderId, message.SenderRole, message.Content, message.CreatedAt });
}
}
public record StartConsultationRequest(Guid DoctorId, string Subject);
public record SendMessageRequest(string Content, string ContentType = "text", string? ImageUrl = null);

View File

@@ -0,0 +1,67 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/follow-ups")]
[Authorize]
public class FollowUpController(FollowUpService followUpService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet]
public async Task<IActionResult> GetFollowUps()
{
var followUps = Role == "doctor"
? await followUpService.GetDoctorFollowUpsAsync(UserId)
: await followUpService.GetPatientFollowUpsAsync(UserId);
return Ok(followUps.Select(f => new
{
f.Id, f.PatientId, f.DoctorId, f.Title, f.Description,
f.ScheduledAt, f.Status, f.Notes, f.ReminderEnabled, f.CreatedAt,
PatientName = f.Patient?.Name,
DoctorName = f.Doctor?.Name,
}));
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetFollowUp(Guid id)
{
var followUp = await followUpService.GetByIdAsync(id);
if (followUp == null) return NotFound(new { message = "复查不存在" });
return Ok(new
{
followUp.Id, followUp.PatientId, followUp.DoctorId, followUp.Title,
followUp.Description, followUp.ScheduledAt, followUp.Status,
followUp.Notes, followUp.ReminderEnabled, followUp.CreatedAt,
});
}
[HttpPost]
public async Task<IActionResult> AddFollowUp([FromBody] FollowUpCreateRequest request)
{
var followUp = await followUpService.AddAsync(UserId, request.Title, request.Description,
request.ScheduledAt, request.ReminderEnabled);
return Ok(new { followUp.Id, followUp.Title, followUp.Status });
}
[HttpPut("{id:guid}")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> UpdateFollowUp(Guid id, [FromBody] FollowUpUpdateRequest request)
{
var followUp = await followUpService.UpdateAsync(id, UserId, request.Title, request.Description,
request.ScheduledAt, request.Status, request.Notes);
if (followUp == null) return NotFound(new { message = "复查不存在" });
return Ok(new { followUp.Id, followUp.Title, followUp.Status });
}
}
public record FollowUpCreateRequest(string Title, string? Description, DateTime ScheduledAt, bool ReminderEnabled = true);
public record FollowUpUpdateRequest(
string? Title, string? Description, DateTime? ScheduledAt, string? Status, string? Notes);

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using System.Text.Json;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/health-records")]
[Authorize]
public class HealthController(HealthService healthService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet]
public async Task<IActionResult> GetRecords([FromQuery] string? type, [FromQuery] int days = 30)
{
var targetUserId = UserId;
// Doctors can query any patient
if (Role == "doctor" && Request.Query.ContainsKey("patientId"))
targetUserId = Guid.Parse(Request.Query["patientId"]!);
var records = await healthService.GetRecordsAsync(targetUserId, type, days);
return Ok(records.Select(r => new
{
r.Id, r.Type, Value = r.Value.RootElement.GetRawText(), r.Unit,
r.RecordedAt, r.Source, r.Notes, r.CreatedAt,
}));
}
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var stats = await healthService.GetStatsAsync(UserId);
return Ok(stats);
}
[HttpGet("latest/{type}")]
public async Task<IActionResult> GetLatest(string type)
{
var record = await healthService.GetLatestAsync(UserId, type);
if (record == null) return Ok((object?)null);
return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source });
}
[HttpPost]
public async Task<IActionResult> AddRecord([FromBody] HealthRecordCreateRequest request)
{
// Validate JSON
try { JsonDocument.Parse(request.ValueJson); }
catch (JsonException) { return BadRequest(new { message = "无效的数据格式" }); }
var record = await healthService.AddRecordAsync(UserId, request.Type, request.ValueJson, request.Unit, request.RecordedAt, request.Notes);
return Ok(new { record.Id, record.Type, Value = record.Value.RootElement.GetRawText(), record.Unit, record.RecordedAt, record.Source });
}
}
public record HealthRecordCreateRequest(string Type, string ValueJson, string Unit, DateTime RecordedAt, string? Notes);

View File

@@ -0,0 +1,80 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/medications")]
[Authorize]
public class MedicationController(MedicationService medicationService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet]
public async Task<IActionResult> GetMedications()
{
var targetUserId = UserId;
if (Role == "doctor" && Request.Query.ContainsKey("patientId"))
targetUserId = Guid.Parse(Request.Query["patientId"]!);
var medications = await medicationService.GetUserMedicationsAsync(targetUserId);
return Ok(medications.Select(m => new
{
m.Id, m.UserId, m.DrugName, m.Dosage, m.Frequency, m.TimeSlots,
m.StartDate, m.EndDate, m.Notes, m.Status, m.CreatedAt,
}));
}
[HttpPost]
public async Task<IActionResult> AddMedication([FromBody] MedicationCreateRequest request)
{
var med = await medicationService.AddAsync(UserId, request.DrugName, request.Dosage,
request.Frequency, request.TimeSlots, request.StartDate, request.EndDate, request.Notes);
return Ok(new { med.Id, med.DrugName, med.Dosage, med.Frequency, med.Status });
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetMedication(Guid id)
{
var med = await medicationService.GetByIdAsync(id);
if (med == null) return NotFound(new { message = "药品不存在" });
return Ok(new
{
med.Id, med.UserId, med.DrugName, med.Dosage, med.Frequency, med.TimeSlots,
med.StartDate, med.EndDate, med.Notes, med.Status, med.CreatedAt,
});
}
[HttpGet("{id:guid}/records")]
public async Task<IActionResult> GetRecords(Guid id)
{
var records = await medicationService.GetRecordsAsync(id);
return Ok(records.Select(r => new
{
r.Id, r.MedicationId, r.TimeSlot, r.TakenAt, r.IsTaken, r.SkippedReason, r.CreatedAt,
}));
}
[HttpPost("{id:guid}/take")]
public async Task<IActionResult> MarkTaken(Guid id, [FromBody] MarkTakenRequest request)
{
var record = await medicationService.MarkTakenAsync(id, UserId, request.TimeSlot);
return Ok(new { record.Id, record.TimeSlot, record.TakenAt, record.IsTaken });
}
[HttpGet("{id:guid}/adherence")]
public async Task<IActionResult> GetAdherence(Guid id)
{
var rate = await medicationService.GetAdherenceRateAsync(id);
return Ok(new { medicationId = id, rate });
}
}
public record MedicationCreateRequest(
string DrugName, string Dosage, string Frequency,
List<string> TimeSlots, DateOnly StartDate, DateOnly? EndDate, string? Notes);
public record MarkTakenRequest(string TimeSlot);

View File

@@ -0,0 +1,46 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/notifications")]
[Authorize]
public class NotificationController(NotificationService notificationService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
[HttpGet]
public async Task<IActionResult> GetNotifications()
{
var notifications = await notificationService.GetUserNotificationsAsync(UserId);
return Ok(notifications.Select(n => new
{
n.Id, n.Type, n.Title, n.Content, n.RelatedId,
n.IsRead, n.ReadAt, n.CreatedAt,
}));
}
[HttpGet("unread-count")]
public async Task<IActionResult> GetUnreadCount()
{
var count = await notificationService.GetUnreadCountAsync(UserId);
return Ok(new { count });
}
[HttpPut("{id:guid}/read")]
public async Task<IActionResult> MarkAsRead(Guid id)
{
await notificationService.MarkAsReadAsync(id);
return Ok(new { message = "已标记为已读" });
}
[HttpPut("read-all")]
public async Task<IActionResult> MarkAllAsRead()
{
await notificationService.MarkAllAsReadAsync(UserId);
return Ok(new { message = "全部已读" });
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/patients")]
[Authorize(Roles = "doctor")]
public class PatientController(PatientService patientService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetPatients([FromQuery] string? search, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var patients = await patientService.GetPatientsAsync(search, null, page, pageSize);
return Ok(patients);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetPatientDetail(Guid id)
{
var patient = await patientService.GetPatientDetailAsync(id);
if (patient == null) return NotFound(new { message = "患者不存在" });
return Ok(patient);
}
}

View File

@@ -0,0 +1,87 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace HealthManager.WebApi.Controllers;
[ApiController]
[Route("api/reports")]
[Authorize]
public class ReportController(ReportService reportService) : ControllerBase
{
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
private string Role => User.FindFirstValue(ClaimTypes.Role)!;
[HttpGet]
public async Task<IActionResult> GetReports()
{
var targetUserId = UserId;
if (Role == "doctor" && Request.Query.ContainsKey("patientId"))
targetUserId = Guid.Parse(Request.Query["patientId"]!);
var reports = await reportService.GetPatientReportsAsync(targetUserId);
return Ok(reports.Select(r => new
{
r.Id, r.PatientId, r.Title, r.Category, r.ImageUrls, r.Status,
r.RiskLevel, r.UploadedAt, r.CompletedAt,
DoctorName = r.Doctor?.Name,
}));
}
[HttpGet("pending")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> GetPending()
{
var reports = await reportService.GetPendingAsync();
return Ok(reports.Select(r => new
{
r.Id, r.PatientId, r.Title, r.Category, r.Status, r.UploadedAt,
PatientName = r.Patient?.Name,
}));
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetReport(Guid id)
{
var report = await reportService.GetByIdAsync(id);
if (report == null) return NotFound(new { message = "报告不存在" });
return Ok(new
{
report.Id, report.PatientId, report.Title, report.Category, report.ImageUrls,
report.Status, report.RiskLevel, report.Summary, report.Suggestions,
report.UploadedAt, report.CompletedAt,
PatientName = report.Patient?.Name,
DoctorName = report.Doctor?.Name,
Items = report.Items.Select(i => new
{
i.Id, i.ItemName, i.ResultValue, i.Unit, i.ReferenceRange, i.IsAbnormal,
}),
});
}
[HttpPost]
public async Task<IActionResult> UploadReport([FromBody] ReportUploadRequest request)
{
var report = await reportService.UploadAsync(UserId, request.Title, request.Category, request.ImageUrls);
return Ok(new { report.Id, report.Title, report.Status });
}
[HttpPost("{id:guid}/interpret")]
[Authorize(Roles = "doctor")]
public async Task<IActionResult> InterpretReport(Guid id, [FromBody] ReportInterpretRequest request)
{
var items = request.Items.Select(i => (i.ItemName, i.ResultValue, i.Unit, i.ReferenceRange, i.IsAbnormal)).ToList();
var report = await reportService.InterpretAsync(id, UserId, request.Summary, items, request.RiskLevel, request.Suggestions);
return Ok(new { report.Id, report.Status, report.RiskLevel });
}
}
public record ReportUploadRequest(string Title, string Category, List<string> ImageUrls);
public record ReportInterpretRequest(
string Summary, List<ReportItemRequest> Items, string RiskLevel, string? Suggestions);
public record ReportItemRequest(
string ItemName, string ResultValue, string? Unit, string? ReferenceRange, bool IsAbnormal);

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<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="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HealthManager.Application\HealthManager.Application.csproj" />
<ProjectReference Include="..\HealthManager.Infrastructure\HealthManager.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@HealthManager.WebApi_HostAddress = http://localhost:5133
GET {{HealthManager.WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,40 @@
using System.Security.Claims;
using HealthManager.Application.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace HealthManager.WebApi.Hubs;
[Authorize]
public class ChatHub(ConsultationService consultationService) : Hub
{
public async Task JoinConsultation(Guid consultationId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"consultation_{consultationId}");
}
public async Task LeaveConsultation(Guid consultationId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"consultation_{consultationId}");
}
public async Task SendMessage(Guid consultationId, string content)
{
var userId = Guid.Parse(Context.User!.FindFirstValue(ClaimTypes.NameIdentifier)!);
var role = Context.User!.FindFirstValue(ClaimTypes.Role)!;
var message = await consultationService.SendMessageAsync(consultationId, userId, role, content);
await Clients.Group($"consultation_{consultationId}").SendAsync("ReceiveMessage", new
{
message.Id,
message.SenderId,
message.SenderRole,
message.ContentType,
message.Content,
message.ImageUrl,
message.IsRead,
message.CreatedAt,
});
}
}

View File

@@ -0,0 +1,101 @@
using System.Text;
using HealthManager.Domain.Interfaces;
using HealthManager.Application.Services;
using HealthManager.Infrastructure.Data;
using HealthManager.Infrastructure.Services;
using HealthManager.WebApi.Hubs;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Swashbuckle.AspNetCore.SwaggerGen;
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// JWT
var jwtSecret = builder.Configuration["Jwt:Secret"]!;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// Services
builder.Services.AddSingleton<IJwtProvider, JwtProvider>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<HealthService>();
builder.Services.AddScoped<MedicationService>();
builder.Services.AddScoped<ConsultationService>();
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<FollowUpService>();
builder.Services.AddScoped<PatientService>();
builder.Services.AddScoped<NotificationService>();
// SignalR
builder.Services.AddSignalR();
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("Dev", policy =>
{
policy.WithOrigins("http://localhost:5173", "http://localhost:5174", "http://localhost:5175")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
builder.Services.AddControllers();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("Dev");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<ChatHub>("/hubs/chat");
// Auto-migrate and seed on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
await DataSeeder.SeedAsync(db);
}
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5133",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7241;http://localhost:5133",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,25 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=health_manager;Username=postgres;Password=postgres123"
},
"Jwt": {
"Secret": "health-manager-jwt-secret-key-2026-super-secure-long-enough!",
"Issuer": "HealthManager",
"Audience": "HealthManagerApp"
},
"Redis": {
"Connection": "localhost:6379"
},
"MinIO": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin"
}
}