Initial commit: 健康管家 AI 健康陪伴助手
- Backend: .NET 10 Minimal API + EF Core + PostgreSQL - Frontend: Flutter + Riverpod + GoRouter + Dio - AI: DeepSeek LLM + Qwen VLM (OpenAI-compatible) - Auth: SMS + JWT (access/refresh tokens) - Features: AI chat, health tracking, medication management, diet analysis, exercise plans, doctor consultations, report analysis
This commit is contained in:
143
backend/tests/Health.Tests/AuthTests.cs
Normal file
143
backend/tests/Health.Tests/AuthTests.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 认证流程测试
|
||||
/// </summary>
|
||||
public class AuthTests
|
||||
{
|
||||
private AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
private IConfiguration CreateConfig()
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
{ "JWT_SECRET", "test-secret-key-for-unit-tests-min-32-chars!!" },
|
||||
{ "JWT_ISSUER", "health-manager" },
|
||||
{ "JWT_AUDIENCE", "health-manager-app" }
|
||||
};
|
||||
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSms_Should_Create_VerificationCode()
|
||||
{
|
||||
// Arrange
|
||||
using var db = CreateDbContext();
|
||||
var sms = new SmsService();
|
||||
|
||||
// Act
|
||||
var code = sms.GenerateCode();
|
||||
db.VerificationCodes.Add(new VerificationCode
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = "13800138000", Code = code,
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var saved = await db.VerificationCodes.FirstOrDefaultAsync(v => v.Phone == "13800138000");
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(code, saved!.Code);
|
||||
Assert.True(saved.ExpiresAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_Should_Create_User_And_Return_Token()
|
||||
{
|
||||
// Arrange
|
||||
using var db = CreateDbContext();
|
||||
var config = CreateConfig();
|
||||
var jwt = new JwtProvider(config);
|
||||
|
||||
var phone = "13800138000";
|
||||
|
||||
// Act
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = phone, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var accessToken = jwt.GenerateAccessToken(user.Id, phone);
|
||||
var refreshToken = jwt.GenerateRefreshToken();
|
||||
db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = user.Id, Token = refreshToken, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(accessToken);
|
||||
Assert.True(accessToken.Length > 50);
|
||||
Assert.NotNull(refreshToken);
|
||||
Assert.True(refreshToken.Length > 50);
|
||||
|
||||
var savedUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == phone);
|
||||
Assert.NotNull(savedUser);
|
||||
|
||||
var savedToken = await db.RefreshTokens.FirstOrDefaultAsync(t => t.Token == refreshToken);
|
||||
Assert.NotNull(savedToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshToken_Should_Revoke_Old_And_Issue_New()
|
||||
{
|
||||
// Arrange
|
||||
using var db = CreateDbContext();
|
||||
var config = CreateConfig();
|
||||
var jwt = new JwtProvider(config);
|
||||
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
|
||||
var oldRefresh = jwt.GenerateRefreshToken();
|
||||
db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = user.Id, Token = oldRefresh, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Act — 吊销旧 token,签发新 token
|
||||
var old = await db.RefreshTokens.FirstOrDefaultAsync(t => t.Token == oldRefresh && !t.IsRevoked);
|
||||
Assert.NotNull(old);
|
||||
old!.IsRevoked = true;
|
||||
|
||||
var newToken = jwt.GenerateRefreshToken();
|
||||
db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), UserId = user.Id, Token = newToken, ExpiresAt = DateTime.UtcNow.AddDays(30) });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
var revoked = await db.RefreshTokens.FirstOrDefaultAsync(t => t.Token == oldRefresh);
|
||||
Assert.True(revoked!.IsRevoked);
|
||||
|
||||
var active = await db.RefreshTokens.FirstOrDefaultAsync(t => t.Token == newToken && !t.IsRevoked);
|
||||
Assert.NotNull(active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_Expired_Should_Fail_Login()
|
||||
{
|
||||
// Arrange
|
||||
using var db = CreateDbContext();
|
||||
var expiredCode = new VerificationCode
|
||||
{
|
||||
Id = Guid.NewGuid(), Phone = "13800138000", Code = "123456",
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(-1), // 已过期
|
||||
};
|
||||
db.VerificationCodes.Add(expiredCode);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var valid = await db.VerificationCodes
|
||||
.Where(v => v.Phone == "13800138000" && v.Code == "123456"
|
||||
&& v.ExpiresAt > DateTime.UtcNow && !v.IsUsed)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Assert — 过期的验证码查不到
|
||||
Assert.Null(valid);
|
||||
}
|
||||
}
|
||||
221
backend/tests/Health.Tests/EntityTests.cs
Normal file
221
backend/tests/Health.Tests/EntityTests.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 实体和数据库操作测试
|
||||
/// </summary>
|
||||
public class EntityTests
|
||||
{
|
||||
private AppDbContext CreateDbContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new AppDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_HealthRecord_Should_Persist()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||
Systolic = 128, Diastolic = 82, Unit = "mmHg",
|
||||
Source = HealthRecordSource.AiEntry, RecordedAt = DateTime.UtcNow,
|
||||
IsAbnormal = false,
|
||||
};
|
||||
db.HealthRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.HealthRecords.FirstOrDefaultAsync(r => r.UserId == user.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(128, saved!.Systolic);
|
||||
Assert.Equal(82, saved.Diastolic);
|
||||
Assert.Equal(HealthMetricType.BloodPressure, saved.MetricType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abnormal_BloodPressure_Should_Flag_IsAbnormal()
|
||||
{
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Systolic = 155, Diastolic = 95, MetricType = HealthMetricType.BloodPressure,
|
||||
};
|
||||
var isAbnormal = record.Systolic >= 140 || record.Diastolic >= 90
|
||||
|| record.Systolic <= 89 || record.Diastolic <= 59;
|
||||
|
||||
Assert.True(isAbnormal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Normal_BloodPressure_Should_Not_Flag()
|
||||
{
|
||||
var record = new HealthRecord
|
||||
{
|
||||
Systolic = 128, Diastolic = 82, MetricType = HealthMetricType.BloodPressure,
|
||||
};
|
||||
var isAbnormal = record.Systolic >= 140 || record.Diastolic >= 90
|
||||
|| record.Systolic <= 89 || record.Diastolic <= 59;
|
||||
|
||||
Assert.False(isAbnormal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Medication_Should_Persist_With_TimeOfDay()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.AiEntry, IsActive = true,
|
||||
};
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.Medications.FirstOrDefaultAsync(m => m.UserId == user.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("阿司匹林", saved!.Name);
|
||||
Assert.Equal("100mg", saved.Dosage);
|
||||
Assert.Single(saved.TimeOfDay);
|
||||
Assert.Equal(8, saved.TimeOfDay[0].Hour);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Medication_Confirm_Should_Create_Log()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
|
||||
var med = new Medication { Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg", Frequency = MedicationFrequency.Daily, Source = MedicationSource.Prescription, IsActive = true };
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 打卡
|
||||
var log = new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = med.Id, UserId = user.Id,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = new TimeOnly(20, 0),
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.MedicationLogs.Add(log);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == med.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(MedicationLogStatus.Taken, saved!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversation_With_Messages_Should_Work()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var conv = new Conversation { Id = Guid.NewGuid(), UserId = user.Id, AgentType = AgentType.Default, Title = "血压咨询", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Conversations.Add(conv);
|
||||
|
||||
var msg1 = new ConversationMessage { Id = Guid.NewGuid(), ConversationId = conv.Id, Role = MessageRole.User, Content = "血压 135/85", CreatedAt = DateTime.UtcNow };
|
||||
var msg2 = new ConversationMessage { Id = Guid.NewGuid(), ConversationId = conv.Id, Role = MessageRole.Assistant, Content = "收到!已记录", CreatedAt = DateTime.UtcNow };
|
||||
db.ConversationMessages.AddRange(msg1, msg2);
|
||||
conv.MessageCount = 2;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messages = await db.ConversationMessages.Where(m => m.ConversationId == conv.Id).OrderBy(m => m.CreatedAt).ToListAsync();
|
||||
Assert.Equal(2, messages.Count);
|
||||
Assert.Equal("血压 135/85", messages[0].Content);
|
||||
Assert.Equal("收到!已记录", messages[1].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DietRecord_With_FoodItems_Should_Work()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var diet = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now),
|
||||
};
|
||||
diet.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "1碗", Calories = 174, SortOrder = 1 });
|
||||
diet.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "5块", Calories = 470, Warning = "脂肪偏高", SortOrder = 2 });
|
||||
db.DietRecords.Add(diet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.DietRecords.Include(d => d.FoodItems).FirstOrDefaultAsync(d => d.UserId == user.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(2, saved!.FoodItems.Count);
|
||||
Assert.Equal(644, saved.TotalCalories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthArchive_Should_Store_All_Fields()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var archive = new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, Diagnosis = "冠心病",
|
||||
SurgeryType = "PCI支架植入术", SurgeryDate = new DateOnly(2026, 3, 15),
|
||||
Allergies = ["青霉素"],
|
||||
DietRestrictions = ["低盐", "低脂"],
|
||||
ChronicDiseases = ["高血压", "高血脂"],
|
||||
FamilyHistory = "父亲冠心病",
|
||||
};
|
||||
db.HealthArchives.Add(archive);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == user.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("冠心病", saved!.Diagnosis);
|
||||
Assert.Equal(2, saved.DietRestrictions.Count);
|
||||
Assert.Contains("低盐", saved.DietRestrictions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_Weekly_Tracking_Should_Work()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var user = new User { Id = Guid.NewGuid(), Phone = "13800138000", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var monday = new DateOnly(2026, 6, 1);
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, IsRestDay = true });
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == user.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(3, saved!.Items.Count);
|
||||
|
||||
var completed = saved.Items.Count(i => i.IsCompleted);
|
||||
Assert.Equal(1, completed);
|
||||
}
|
||||
}
|
||||
29
backend/tests/Health.Tests/Health.Tests.csproj
Normal file
29
backend/tests/Health.Tests/Health.Tests.csproj
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Health.WebApi\Health.WebApi.csproj" />
|
||||
<ProjectReference Include="..\..\src\Health.Domain\Health.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Health.Infrastructure\Health.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
backend/tests/Health.Tests/UnitTest1.cs
Normal file
10
backend/tests/Health.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Health.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user