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
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.pfx
|
||||||
|
|
||||||
|
# .NET build outputs
|
||||||
|
backend/**/bin/
|
||||||
|
backend/**/obj/
|
||||||
|
|
||||||
|
# Flutter build outputs
|
||||||
|
health_app/build/
|
||||||
|
health_app/.dart_tool/
|
||||||
|
health_app/.flutter-plugins*
|
||||||
|
health_app/.packages
|
||||||
|
|
||||||
|
# Android
|
||||||
|
health_app/android/.gradle/
|
||||||
|
health_app/android/app/build/
|
||||||
|
health_app/android/local.properties
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
health_app/ios/Pods/
|
||||||
|
health_app/ios/.symlinks/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
26
CLAUDE.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# AI APP — 编码规范
|
||||||
|
|
||||||
|
## 后端 C# (.NET 10)
|
||||||
|
|
||||||
|
- 主构造函数:`public class Service(DbContext db) { }`
|
||||||
|
- 静态方法必须标记 `static`
|
||||||
|
- 集合表达式:`[]` / `[..src]`
|
||||||
|
- `TryGetValue` 替代 `GetValueOrDefault`
|
||||||
|
- 空 catch 必须指定异常类型
|
||||||
|
- DTO 用 `record` 类型
|
||||||
|
- `private static readonly` 缓存可复用对象
|
||||||
|
- 本地函数优于 `Func<T>` 赋值
|
||||||
|
- file-scoped namespace、global using、target-typed `new()`、`Nullable=enable`
|
||||||
|
- Minimal API 扩展方法模式:`public static class XxxEndpoints { }`
|
||||||
|
- AI 请求用 `HttpClient` 直连,不引入第三方 AI SDK
|
||||||
|
- **文件命名 snake_case**:`auth_service.cs`、`auth_endpoints.cs`
|
||||||
|
|
||||||
|
## 前端 Flutter (Dart)
|
||||||
|
|
||||||
|
-前端用sqlite存储信息,不用shared_preferences
|
||||||
|
- 用 Riverpod 管理所有状态和页面跳转
|
||||||
|
## 运行与测试
|
||||||
|
|
||||||
|
- 测试时自行启动后端,通过浏览器 DevTools 发送请求验证
|
||||||
|
- Flutter 用 `flutter run` 连模拟器/真机
|
||||||
|
|
||||||
22
backend/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 数据库
|
||||||
|
DB_CONNECTION=Host=localhost;Database=health_manager;Username=postgres;Password=postgres123
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-jwt-secret-min-32-chars
|
||||||
|
JWT_ISSUER=health-manager
|
||||||
|
JWT_AUDIENCE=health-manager-app
|
||||||
|
|
||||||
|
# DeepSeek LLM
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
DEEPSEEK_API_KEY=sk-your-key-here
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# 千问 VLM
|
||||||
|
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
QWEN_API_KEY=sk-your-key-here
|
||||||
|
QWEN_VISION_MODEL=qwen-vl-max
|
||||||
|
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin123
|
||||||
11
backend/HealthManager.slnx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Health.Application/Health.Application.csproj" />
|
||||||
|
<Project Path="src/Health.Domain/Health.Domain.csproj" />
|
||||||
|
<Project Path="src/Health.Infrastructure/Health.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/Health.WebApi/Health.WebApi.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Health.Tests/Health.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
13
backend/src/Health.Application/Health.Application.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
53
backend/src/Health.Domain/Entities/Consultation.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 问诊会话
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Consultation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public Guid DoctorId { get; set; }
|
||||||
|
public ConsultationStatus Status { get; set; }
|
||||||
|
public int Month { get; set; } // 所属月份,用于配额计算
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public Doctor Doctor { get; set; } = null!;
|
||||||
|
public ICollection<ConsultationMessage> Messages { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 问诊消息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConsultationMessage
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ConsultationId { get; set; }
|
||||||
|
public ConsultationSenderType SenderType { get; set; }
|
||||||
|
public string? SenderName { get; set; }
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Consultation Consultation { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 医生
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Doctor
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Title { get; set; } // 主任医师/副主任医师
|
||||||
|
public string? Department { get; set; } // 心血管内科/营养科
|
||||||
|
public string? AvatarUrl { get; set; }
|
||||||
|
public string? Introduction { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public ICollection<Consultation> Consultations { get; set; } = [];
|
||||||
|
}
|
||||||
39
backend/src/Health.Domain/Entities/Conversation.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 对话会话
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Conversation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public AgentType AgentType { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Summary { get; set; } // 侧滑抽屉显示用摘要
|
||||||
|
public int MessageCount { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public ICollection<ConversationMessage> Messages { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 对话消息
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConversationMessage
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ConversationId { get; set; }
|
||||||
|
public MessageRole Role { get; set; }
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public string? Intent { get; set; } // health_record / diet / medication / exercise / report / chat
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public string? MetadataJson { get; set; } // 结构化数据(录入数值、食物列表、卡片数据等)
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Conversation Conversation { get; set; } = null!;
|
||||||
|
}
|
||||||
39
backend/src/Health.Domain/Entities/DietRecord.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 饮食记录
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DietRecord
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public MealType MealType { get; set; }
|
||||||
|
public int? TotalCalories { get; set; }
|
||||||
|
public int? HealthScore { get; set; } // 1-5 星
|
||||||
|
public DateOnly RecordedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public ICollection<DietFoodItem> FoodItems { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 饮食记录中的食物条目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DietFoodItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid DietRecordId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Portion { get; set; }
|
||||||
|
public int? Calories { get; set; }
|
||||||
|
public decimal? ProteinGrams { get; set; }
|
||||||
|
public decimal? CarbsGrams { get; set; }
|
||||||
|
public decimal? FatGrams { get; set; }
|
||||||
|
public string? Warning { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
public DietRecord DietRecord { get; set; } = null!;
|
||||||
|
}
|
||||||
33
backend/src/Health.Domain/Entities/ExercisePlan.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 运动计划(按周)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExercisePlan
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public DateOnly WeekStartDate { get; set; } // 本周一
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public ICollection<ExercisePlanItem> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 运动计划每日条目
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExercisePlanItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public int DayOfWeek { get; set; } // 0=周一, 6=周日
|
||||||
|
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
|
||||||
|
public int DurationMinutes { get; set; }
|
||||||
|
public bool IsCompleted { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public bool IsRestDay { get; set; }
|
||||||
|
|
||||||
|
public ExercisePlan Plan { get; set; } = null!;
|
||||||
|
}
|
||||||
23
backend/src/Health.Domain/Entities/HealthRecord.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康数据记录(血压/心率/血糖/血氧/体重)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HealthRecord
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public HealthMetricType MetricType { get; set; }
|
||||||
|
public int? Systolic { get; set; } // 血压收缩压
|
||||||
|
public int? Diastolic { get; set; } // 血压舒张压
|
||||||
|
public decimal? Value { get; set; } // 通用数值(心率/血糖/血氧/体重)
|
||||||
|
public string? Unit { get; set; } // 单位
|
||||||
|
public HealthRecordSource Source { get; set; }
|
||||||
|
public bool IsAbnormal { get; set; }
|
||||||
|
public DateTime RecordedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
41
backend/src/Health.Domain/Entities/Medication.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用药计划
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Medication
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Dosage { get; set; }
|
||||||
|
public MedicationFrequency Frequency { get; set; }
|
||||||
|
public List<TimeOnly> TimeOfDay { get; set; } = []; // PostgreSQL TIME[] 数组
|
||||||
|
public DateOnly? StartDate { get; set; }
|
||||||
|
public DateOnly? EndDate { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public MedicationSource Source { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
public ICollection<MedicationLog> Logs { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用药打卡记录
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MedicationLog
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid MedicationId { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public MedicationLogStatus Status { get; set; }
|
||||||
|
public TimeOnly ScheduledTime { get; set; }
|
||||||
|
public DateTime? ConfirmedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public Medication Medication { get; set; } = null!;
|
||||||
|
}
|
||||||
26
backend/src/Health.Domain/Entities/Report.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查报告
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Report
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string FileUrl { get; set; } = string.Empty;
|
||||||
|
public ReportFileType FileType { get; set; }
|
||||||
|
public ReportCategory Category { get; set; }
|
||||||
|
public string? AiSummary { get; set; } // AI 预解读结果
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public string? AiIndicators { get; set; } // JSONB: [{name, value, unit, range, status}]
|
||||||
|
public ReportStatus Status { get; set; }
|
||||||
|
public string? DoctorComment { get; set; } // 医生审核意见
|
||||||
|
public string? DoctorName { get; set; }
|
||||||
|
public DateTime? ReviewedAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
99
backend/src/Health.Domain/Entities/SupportEntities.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复查/随访计划
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FollowUp
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? DoctorName { get; set; }
|
||||||
|
public string? Department { get; set; }
|
||||||
|
public DateTime ScheduledAt { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public FollowUpStatus Status { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康档案
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HealthArchive
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string? Diagnosis { get; set; } // 主要诊断
|
||||||
|
public string? SurgeryType { get; set; } // 手术类型
|
||||||
|
public DateOnly? SurgeryDate { get; set; } // 手术日期
|
||||||
|
public List<string> Allergies { get; set; } = []; // 过敏信息
|
||||||
|
public List<string> DietRestrictions { get; set; } = []; // 饮食限制
|
||||||
|
public List<string> ChronicDiseases { get; set; } = []; // 慢病史
|
||||||
|
public string? FamilyHistory { get; set; } // 家族病史
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 刷新令牌
|
||||||
|
/// </summary>
|
||||||
|
public sealed 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 bool IsRevoked { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 短信验证码
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerificationCode
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public bool IsUsed { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通知偏好
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NotificationPreference
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public bool MedicationReminder { get; set; } = true;
|
||||||
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
|
public bool DoctorReply { get; set; } = true;
|
||||||
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设备推送 token
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceToken
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public string Platform { get; set; } = string.Empty; // ios / android
|
||||||
|
public string PushToken { get; set; } = string.Empty;
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
29
backend/src/Health.Domain/Entities/User.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户(患者)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class User
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Gender { get; set; }
|
||||||
|
public DateOnly? BirthDate { get; set; }
|
||||||
|
public string? AvatarUrl { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// 导航属性
|
||||||
|
public ICollection<HealthRecord> HealthRecords { get; set; } = [];
|
||||||
|
public ICollection<Medication> Medications { get; set; } = [];
|
||||||
|
public ICollection<DietRecord> DietRecords { get; set; } = [];
|
||||||
|
public ICollection<ExercisePlan> ExercisePlans { get; set; } = [];
|
||||||
|
public ICollection<Report> Reports { get; set; } = [];
|
||||||
|
public ICollection<Conversation> Conversations { get; set; } = [];
|
||||||
|
public ICollection<Consultation> Consultations { get; set; } = [];
|
||||||
|
public ICollection<FollowUp> FollowUps { get; set; } = [];
|
||||||
|
public ICollection<DeviceToken> DeviceTokens { get; set; } = [];
|
||||||
|
public HealthArchive? HealthArchive { get; set; }
|
||||||
|
public NotificationPreference? NotificationPreference { get; set; }
|
||||||
|
}
|
||||||
152
backend/src/Health.Domain/Enums/HealthEnums.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
namespace Health.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康指标类型
|
||||||
|
/// </summary>
|
||||||
|
public enum HealthMetricType
|
||||||
|
{
|
||||||
|
BloodPressure, // 血压(收缩压+舒张压)
|
||||||
|
HeartRate, // 心率
|
||||||
|
Glucose, // 血糖
|
||||||
|
SpO2, // 血氧
|
||||||
|
Weight // 体重
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据录入来源
|
||||||
|
/// </summary>
|
||||||
|
public enum HealthRecordSource
|
||||||
|
{
|
||||||
|
AiEntry, // AI 对话录入
|
||||||
|
DeviceSync, // 设备自动同步
|
||||||
|
Manual // 手动录入
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 餐次类型
|
||||||
|
/// </summary>
|
||||||
|
public enum MealType
|
||||||
|
{
|
||||||
|
Breakfast, // 早餐
|
||||||
|
Lunch, // 午餐
|
||||||
|
Dinner, // 晚餐
|
||||||
|
Snack // 加餐
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用药计划来源
|
||||||
|
/// </summary>
|
||||||
|
public enum MedicationSource
|
||||||
|
{
|
||||||
|
Prescription, // 处方
|
||||||
|
AiEntry, // AI 对话
|
||||||
|
Manual // 手动
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服药打卡状态
|
||||||
|
/// </summary>
|
||||||
|
public enum MedicationLogStatus
|
||||||
|
{
|
||||||
|
Taken, // 已服用
|
||||||
|
Missed, // 漏服
|
||||||
|
Skipped // 跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用药频次
|
||||||
|
/// </summary>
|
||||||
|
public enum MedicationFrequency
|
||||||
|
{
|
||||||
|
Daily, // 每天一次
|
||||||
|
TwiceDaily, // 每天两次
|
||||||
|
ThreeTimesDaily, // 每天三次
|
||||||
|
Weekly, // 每周
|
||||||
|
AsNeeded // 必要时
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报告状态
|
||||||
|
/// </summary>
|
||||||
|
public enum ReportStatus
|
||||||
|
{
|
||||||
|
PendingDoctor, // 待医生确认
|
||||||
|
DoctorReviewed // 医生已确认
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报告文件类型
|
||||||
|
/// </summary>
|
||||||
|
public enum ReportFileType
|
||||||
|
{
|
||||||
|
Image, // 图片
|
||||||
|
Pdf // PDF
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 报告类别
|
||||||
|
/// </summary>
|
||||||
|
public enum ReportCategory
|
||||||
|
{
|
||||||
|
BloodTest, // 血常规
|
||||||
|
Biochemistry, // 生化全项
|
||||||
|
Ecg, // 心电图
|
||||||
|
Ultrasound, // 彩超
|
||||||
|
Discharge, // 出院小结
|
||||||
|
Other // 其他
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 问诊会话状态
|
||||||
|
/// </summary>
|
||||||
|
public enum ConsultationStatus
|
||||||
|
{
|
||||||
|
AiTalking, // AI 分身对话中
|
||||||
|
WaitingDoctor, // 等待医生
|
||||||
|
DoctorReplied, // 医生已回复
|
||||||
|
Closed // 已结束
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 问诊消息发送方类型
|
||||||
|
/// </summary>
|
||||||
|
public enum ConsultationSenderType
|
||||||
|
{
|
||||||
|
User, // 患者
|
||||||
|
Doctor, // 医生
|
||||||
|
Ai // AI 分身
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 复查随访状态
|
||||||
|
/// </summary>
|
||||||
|
public enum FollowUpStatus
|
||||||
|
{
|
||||||
|
Upcoming, // 即将到来
|
||||||
|
Completed, // 已完成
|
||||||
|
Cancelled // 已取消
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI Agent 类型
|
||||||
|
/// </summary>
|
||||||
|
public enum AgentType
|
||||||
|
{
|
||||||
|
Default, // 默认对话
|
||||||
|
Consultation, // AI 问诊
|
||||||
|
Health, // 记数据
|
||||||
|
Diet, // 拍饮食
|
||||||
|
Medication, // 药管家
|
||||||
|
Report, // 看报告
|
||||||
|
Exercise // 运动计划
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对话消息角色
|
||||||
|
/// </summary>
|
||||||
|
public enum MessageRole
|
||||||
|
{
|
||||||
|
User, // 用户
|
||||||
|
Assistant, // AI
|
||||||
|
Tool // 工具返回
|
||||||
|
}
|
||||||
9
backend/src/Health.Domain/Health.Domain.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
152
backend/src/Health.Infrastructure/AI/AiClients.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DeepSeek LLM 客户端(对话 + Tool Calling)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeepSeekClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
public DeepSeekClient(HttpClient http, IConfiguration config)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
|
||||||
|
_jsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流式 Chat Completions
|
||||||
|
/// </summary>
|
||||||
|
public async IAsyncEnumerable<string> ChatStreamAsync(
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
List<ToolDefinition>? tools = null,
|
||||||
|
int maxTokens = 2048,
|
||||||
|
float temperature = 0.7f,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model, Messages = messages, Stream = true,
|
||||||
|
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||||
|
};
|
||||||
|
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions") { Content = content };
|
||||||
|
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync(ct)) != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
if (!line.StartsWith("data: ")) continue;
|
||||||
|
var data = line["data: ".Length..];
|
||||||
|
if (data == "[DONE]") break;
|
||||||
|
yield return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非流式 Chat Completions(用于 Tool Calling)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChatCompletionResponse> ChatAsync(
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
List<ToolDefinition>? tools = null,
|
||||||
|
int maxTokens = 2048,
|
||||||
|
float temperature = 0.7f,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model, Messages = messages, Stream = false,
|
||||||
|
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||||
|
};
|
||||||
|
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 千问 VL 视觉客户端(食物识别 + 报告解读)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QwenVisionClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
public QwenVisionClient(HttpClient http, IConfiguration config)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_model = config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
|
||||||
|
_jsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatCompletionResponse> VisionAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
List<string> imageUrls,
|
||||||
|
string? userText = null,
|
||||||
|
int maxTokens = 2048,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var messages = new List<ChatMessage>();
|
||||||
|
if (!string.IsNullOrEmpty(systemPrompt))
|
||||||
|
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
||||||
|
|
||||||
|
var contentParts = new List<object>();
|
||||||
|
foreach (var url in imageUrls)
|
||||||
|
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
||||||
|
if (!string.IsNullOrEmpty(userText))
|
||||||
|
contentParts.Add(new { type = "text", text = userText });
|
||||||
|
|
||||||
|
var userMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "user",
|
||||||
|
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
|
||||||
|
};
|
||||||
|
messages.Add(userMessage);
|
||||||
|
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
var response = await _http.PostAsync("chat/completions", content, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
backend/src/Health.Infrastructure/AI/OpenAiCompatibleClient.cs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAI 兼容协议 HTTP 客户端,统一调用 DeepSeek / 千问 VL
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenAiCompatibleClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _model;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
public OpenAiCompatibleClient(string baseUrl, string apiKey, string model)
|
||||||
|
{
|
||||||
|
_http = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"),
|
||||||
|
Timeout = TimeSpan.FromSeconds(60)
|
||||||
|
};
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||||
|
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
_model = model;
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 流式 Chat Completions(SSE)
|
||||||
|
/// </summary>
|
||||||
|
public async IAsyncEnumerable<string> ChatStreamAsync(
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
List<ToolDefinition>? tools = null,
|
||||||
|
int maxTokens = 2048,
|
||||||
|
float temperature = 0.7f)
|
||||||
|
{
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model,
|
||||||
|
Messages = messages,
|
||||||
|
Stream = true,
|
||||||
|
MaxTokens = maxTokens,
|
||||||
|
Temperature = temperature,
|
||||||
|
Tools = tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tools?.Count > 0)
|
||||||
|
request.ToolChoice = "auto";
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
|
||||||
|
{
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||||
|
|
||||||
|
var response = await _http.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync()) != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
if (!line.StartsWith("data: ")) continue;
|
||||||
|
|
||||||
|
var data = line["data: ".Length..];
|
||||||
|
if (data == "[DONE]") break;
|
||||||
|
|
||||||
|
yield return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非流式 Chat Completions
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChatCompletionResponse> ChatAsync(
|
||||||
|
List<ChatMessage> messages,
|
||||||
|
List<ToolDefinition>? tools = null,
|
||||||
|
int maxTokens = 2048,
|
||||||
|
float temperature = 0.7f)
|
||||||
|
{
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model,
|
||||||
|
Messages = messages,
|
||||||
|
Stream = false,
|
||||||
|
MaxTokens = maxTokens,
|
||||||
|
Temperature = temperature,
|
||||||
|
Tools = tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tools?.Count > 0)
|
||||||
|
request.ToolChoice = "auto";
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var response = await _http.PostAsync("chat/completions", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vision 图片理解(非流式)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChatCompletionResponse> VisionAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
List<string> imageUrls,
|
||||||
|
string? userText = null,
|
||||||
|
int maxTokens = 2048)
|
||||||
|
{
|
||||||
|
var messages = new List<ChatMessage>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(systemPrompt))
|
||||||
|
messages.Add(new ChatMessage { Role = "system", Content = systemPrompt });
|
||||||
|
|
||||||
|
// 构建多模态消息内容
|
||||||
|
var contentParts = new List<object>();
|
||||||
|
foreach (var url in imageUrls)
|
||||||
|
contentParts.Add(new { type = "image_url", image_url = new { url } });
|
||||||
|
if (!string.IsNullOrEmpty(userText))
|
||||||
|
contentParts.Add(new { type = "text", text = userText });
|
||||||
|
|
||||||
|
var userMessage = new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "user",
|
||||||
|
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
messages.Add(userMessage);
|
||||||
|
|
||||||
|
var request = new ChatCompletionRequest
|
||||||
|
{
|
||||||
|
Model = _model,
|
||||||
|
Messages = messages,
|
||||||
|
MaxTokens = maxTokens,
|
||||||
|
Stream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
var response = await _http.PostAsync("chat/completions", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 请求/响应模型
|
||||||
|
|
||||||
|
public sealed class ChatCompletionRequest
|
||||||
|
{
|
||||||
|
public string Model { get; set; } = string.Empty;
|
||||||
|
public List<ChatMessage> Messages { get; set; } = [];
|
||||||
|
public bool Stream { get; set; }
|
||||||
|
public int MaxTokens { get; set; } = 2048;
|
||||||
|
public float Temperature { get; set; } = 0.7f;
|
||||||
|
public List<ToolDefinition>? Tools { get; set; }
|
||||||
|
public string? ToolChoice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChatMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public string? ToolCallId { get; set; }
|
||||||
|
public List<ToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolDefinition
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
public ToolFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolFunction
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public object Parameters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolCall
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Type { get; set; } = "function";
|
||||||
|
public ToolCallFunction Function { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ToolCallFunction
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Arguments { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChatCompletionResponse
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public List<Choice> Choices { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Choice
|
||||||
|
{
|
||||||
|
public int Index { get; set; }
|
||||||
|
public ResponseMessage? Message { get; set; }
|
||||||
|
public ResponseDelta? Delta { get; set; }
|
||||||
|
public string? FinishReason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResponseMessage
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public List<ToolCall>? ToolCalls { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResponseDelta
|
||||||
|
{
|
||||||
|
public string? Content { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
116
backend/src/Health.Infrastructure/AI/PromptManager.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// System Prompt 模板管理
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PromptManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定 Agent 的 System Prompt
|
||||||
|
/// </summary>
|
||||||
|
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||||
|
{
|
||||||
|
AgentType.Default => DefaultPrompt,
|
||||||
|
AgentType.Consultation => ConsultationPrompt,
|
||||||
|
AgentType.Health => HealthDataPrompt,
|
||||||
|
AgentType.Diet => DietPrompt,
|
||||||
|
AgentType.Medication => MedicationPrompt,
|
||||||
|
AgentType.Report => ReportPrompt,
|
||||||
|
AgentType.Exercise => ExercisePrompt,
|
||||||
|
_ => DefaultPrompt
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string DefaultPrompt = """
|
||||||
|
你是一个心脏术后康复患者的私人 AI 健康管家,名叫"阿福"。
|
||||||
|
语气温暖、专业、像朋友一样关怀患者。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
1. 理解用户的健康需求,解析健康数据
|
||||||
|
2. 主动查看患者近期数据,发现异常时提醒
|
||||||
|
3. 回答健康知识问题
|
||||||
|
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 不要提供超出你能力范围的医疗建议
|
||||||
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
|
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ConsultationPrompt = """
|
||||||
|
你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 每次只问一个问题,不要一次问多个
|
||||||
|
2. 给出 2-3 个快捷选项让患者点击
|
||||||
|
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||||
|
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||||
|
5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50
|
||||||
|
6. 所有分析末尾标注"以上为AI分析,具体请咨询医生"
|
||||||
|
7. 问诊结束给出结构化小结
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string HealthDataPrompt = """
|
||||||
|
你是一个健康数据录入助手。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||||
|
2. 指标明确+数值明确→直接录入
|
||||||
|
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||||
|
4. 时间模糊→取当前时间,在确认卡片中告知用户
|
||||||
|
5. 数值超出正常范围→附带异常提醒
|
||||||
|
6. 录入后生成确认卡片格式的回复
|
||||||
|
|
||||||
|
正常值参考范围:
|
||||||
|
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||||
|
- 心率 60-100 次/分
|
||||||
|
- 空腹血糖 3.9-6.1 mmol/L
|
||||||
|
- 血氧 95-100%
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string DietPrompt = """
|
||||||
|
你是一个营养分析专家,专门为心脏术后患者提供饮食指导。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 收到VLM食物识别结果后,结合患者档案进行综合分析
|
||||||
|
2. 总热量汇总
|
||||||
|
3. 逐项判断"能不能吃"(基于疾病诊断/过敏/饮食限制/近期指标)
|
||||||
|
4. 给出 1-5 星健康评分
|
||||||
|
5. 单项警告 + 整体饮食建议
|
||||||
|
6. 追问餐次归属(早餐/午餐/晚餐/加餐)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string MedicationPrompt = """
|
||||||
|
你是一个用药管理专家。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||||
|
2. "早饭后"等模糊时间→追问具体几点
|
||||||
|
3. 解析完成后展示确认卡片
|
||||||
|
4. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||||
|
5. 回答用药相关疑问
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ReportPrompt = """
|
||||||
|
你是一个医学报告解读专家。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 收到报告图片后,提取所有指标及其数值
|
||||||
|
2. 标注异常指标(偏高/偏低/正常)
|
||||||
|
3. 给出初步分析
|
||||||
|
4. 所有内容标注"AI预解读,待医生确认"
|
||||||
|
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ExercisePrompt = """
|
||||||
|
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||||
|
2. 以周为单位,每天指定运动类型和时长
|
||||||
|
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||||
|
4. 运动强度要循序渐进
|
||||||
|
5. 避免剧烈运动
|
||||||
|
""";
|
||||||
|
}
|
||||||
136
backend/src/Health.Infrastructure/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用程序数据库上下文
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
// 核心业务表
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||||
|
public DbSet<Medication> Medications => Set<Medication>();
|
||||||
|
public DbSet<MedicationLog> MedicationLogs => Set<MedicationLog>();
|
||||||
|
public DbSet<DietRecord> DietRecords => Set<DietRecord>();
|
||||||
|
public DbSet<DietFoodItem> DietFoodItems => Set<DietFoodItem>();
|
||||||
|
public DbSet<ExercisePlan> ExercisePlans => Set<ExercisePlan>();
|
||||||
|
public DbSet<ExercisePlanItem> ExercisePlanItems => Set<ExercisePlanItem>();
|
||||||
|
public DbSet<Report> Reports => Set<Report>();
|
||||||
|
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||||
|
public DbSet<ConversationMessage> ConversationMessages => Set<ConversationMessage>();
|
||||||
|
public DbSet<Consultation> Consultations => Set<Consultation>();
|
||||||
|
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
|
||||||
|
public DbSet<Doctor> Doctors => Set<Doctor>();
|
||||||
|
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||||
|
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
||||||
|
|
||||||
|
// 支撑表
|
||||||
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||||
|
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
||||||
|
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// ---- User ----
|
||||||
|
builder.Entity<User>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- HealthRecord ----
|
||||||
|
builder.Entity<HealthRecord>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(r => new { r.UserId, r.RecordedAt }).IsDescending(false, true);
|
||||||
|
e.HasIndex(r => new { r.UserId, r.MetricType });
|
||||||
|
e.Property(r => r.MetricType).HasConversion<string>();
|
||||||
|
e.Property(r => r.Source).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Medication ----
|
||||||
|
builder.Entity<Medication>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(m => new { m.UserId, m.IsActive });
|
||||||
|
e.Property(m => m.Frequency).HasConversion<string>();
|
||||||
|
e.Property(m => m.Source).HasConversion<string>();
|
||||||
|
e.Property(m => m.TimeOfDay).HasColumnType("time[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<MedicationLog>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(l => new { l.MedicationId, l.CreatedAt }).IsDescending(false, true);
|
||||||
|
e.Property(l => l.Status).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Diet ----
|
||||||
|
builder.Entity<DietRecord>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(d => new { d.UserId, d.RecordedAt }).IsDescending(false, true);
|
||||||
|
e.Property(d => d.MealType).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- ExercisePlan ----
|
||||||
|
builder.Entity<ExercisePlan>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(p => new { p.UserId, p.WeekStartDate }).IsDescending(false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Report ----
|
||||||
|
builder.Entity<Report>(e =>
|
||||||
|
{
|
||||||
|
e.Property(r => r.FileType).HasConversion<string>();
|
||||||
|
e.Property(r => r.Category).HasConversion<string>();
|
||||||
|
e.Property(r => r.Status).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Conversation ----
|
||||||
|
builder.Entity<Conversation>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(c => new { c.UserId, c.UpdatedAt }).IsDescending(false, true);
|
||||||
|
e.Property(c => c.AgentType).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ConversationMessage>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(m => new { m.ConversationId, m.CreatedAt });
|
||||||
|
e.Property(m => m.Role).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Consultation ----
|
||||||
|
builder.Entity<Consultation>(e =>
|
||||||
|
{
|
||||||
|
e.Property(c => c.Status).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ConsultationMessage>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(m => new { m.ConsultationId, m.CreatedAt }).IsDescending(false, true);
|
||||||
|
e.Property(m => m.SenderType).HasConversion<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- VerificationCode ----
|
||||||
|
builder.Entity<VerificationCode>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(v => new { v.Phone, v.CreatedAt }).IsDescending(false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- NotificationPreference ----
|
||||||
|
builder.Entity<NotificationPreference>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(n => n.UserId).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- HealthArchive ----
|
||||||
|
builder.Entity<HealthArchive>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(a => a.UserId).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/src/Health.Infrastructure/Data/DataSeeder.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据库种子数据
|
||||||
|
/// </summary>
|
||||||
|
public static class DataSeeder
|
||||||
|
{
|
||||||
|
public static async Task SeedAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// 种子医生数据
|
||||||
|
if (!db.Doctors.Any())
|
||||||
|
{
|
||||||
|
db.Doctors.AddRange(
|
||||||
|
new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "王建国",
|
||||||
|
Title = "主任医师",
|
||||||
|
Department = "心血管内科",
|
||||||
|
Introduction = "擅长冠心病术后管理、心脏康复指导",
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "李芳",
|
||||||
|
Title = "副主任医师",
|
||||||
|
Department = "营养科",
|
||||||
|
Introduction = "擅长术后营养指导、膳食规划",
|
||||||
|
IsActive = true
|
||||||
|
},
|
||||||
|
new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "张明",
|
||||||
|
Title = "主任医师",
|
||||||
|
Department = "心脏康复科",
|
||||||
|
Introduction = "擅长心脏术后运动康复、心肺功能评估",
|
||||||
|
IsActive = true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
backend/src/Health.Infrastructure/Data/DevDataSeeder.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 开发环境测试数据填充。生产环境不要调用!
|
||||||
|
/// 开关:DEVDATA_ENABLED=true 才会执行
|
||||||
|
/// </summary>
|
||||||
|
public static class DevDataSeeder
|
||||||
|
{
|
||||||
|
public static async Task SeedIfEnabled(AppDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
// 通过环境变量控制:DEVDATA_ENABLED=true 才填充测试数据
|
||||||
|
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||||
|
if (enabled != "true") return;
|
||||||
|
|
||||||
|
// 检查是否已有测试用户(避免重复填充)
|
||||||
|
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||||
|
|
||||||
|
// ---- 创建测试患者 ----
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
||||||
|
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
||||||
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// ---- 健康档案 ----
|
||||||
|
db.HealthArchives.Add(new HealthArchive
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id,
|
||||||
|
Diagnosis = "冠心病", SurgeryType = "PCI支架植入术",
|
||||||
|
SurgeryDate = new DateOnly(2026, 3, 15),
|
||||||
|
Allergies = ["青霉素"], DietRestrictions = ["低盐", "低脂"],
|
||||||
|
ChronicDiseases = ["高血压", "高血脂"],
|
||||||
|
FamilyHistory = "父亲冠心病",
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 健康数据(过去 7 天)----
|
||||||
|
var random = new Random(42);
|
||||||
|
for (int i = 7; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var date = DateTime.UtcNow.AddDays(-i);
|
||||||
|
// 血压
|
||||||
|
db.HealthRecords.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||||
|
Systolic = 120 + random.Next(-5, 15), Diastolic = 75 + random.Next(-5, 10),
|
||||||
|
Unit = "mmHg", Source = HealthRecordSource.AiEntry,
|
||||||
|
IsAbnormal = false, RecordedAt = date,
|
||||||
|
});
|
||||||
|
// 心率
|
||||||
|
db.HealthRecords.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.HeartRate,
|
||||||
|
Value = 68 + random.Next(-5, 10), Unit = "次/分",
|
||||||
|
Source = HealthRecordSource.AiEntry,
|
||||||
|
IsAbnormal = false, RecordedAt = date,
|
||||||
|
});
|
||||||
|
// 血糖
|
||||||
|
db.HealthRecords.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.Glucose,
|
||||||
|
Value = 5.0m + (decimal)(random.NextDouble() * 1.5),
|
||||||
|
Unit = "mmol/L", Source = HealthRecordSource.AiEntry,
|
||||||
|
IsAbnormal = false, RecordedAt = date,
|
||||||
|
});
|
||||||
|
// 血氧
|
||||||
|
db.HealthRecords.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.SpO2,
|
||||||
|
Value = 96 + random.Next(0, 3), Unit = "%",
|
||||||
|
Source = HealthRecordSource.AiEntry,
|
||||||
|
IsAbnormal = false, RecordedAt = date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 一条异常血压
|
||||||
|
db.HealthRecords.Add(new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MetricType = HealthMetricType.BloodPressure,
|
||||||
|
Systolic = 148, Diastolic = 92, Unit = "mmHg",
|
||||||
|
Source = HealthRecordSource.AiEntry, IsAbnormal = true,
|
||||||
|
RecordedAt = DateTime.UtcNow.AddDays(-1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 用药计划 ----
|
||||||
|
db.Medications.Add(new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿司匹林", Dosage = "100mg",
|
||||||
|
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(8, 0)],
|
||||||
|
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||||
|
});
|
||||||
|
db.Medications.Add(new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, Name = "阿托伐他汀", Dosage = "20mg",
|
||||||
|
Frequency = MedicationFrequency.Daily, TimeOfDay = [new TimeOnly(20, 0)],
|
||||||
|
Source = MedicationSource.Prescription, IsActive = true, StartDate = new DateOnly(2026, 4, 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 运动计划 ----
|
||||||
|
var monday = DateOnly.FromDateTime(DateTime.Now.AddDays(-(int)DateTime.Now.DayOfWeek + 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.AddDays(-1) });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 2, ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 3, IsRestDay = true });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 4, ExerciseType = "太极", DurationMinutes = 40 });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 5, IsRestDay = true });
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 6, ExerciseType = "散步", DurationMinutes = 30 });
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
|
||||||
|
// ---- 饮食记录 ----
|
||||||
|
var lunch = new DietRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||||
|
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now),
|
||||||
|
};
|
||||||
|
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
|
||||||
|
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
|
||||||
|
db.DietRecords.Add(lunch);
|
||||||
|
|
||||||
|
// ---- 复查计划 ----
|
||||||
|
db.FollowUps.Add(new FollowUp
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, Title = "心内科复查",
|
||||||
|
DoctorName = "王建国", Department = "心血管内科",
|
||||||
|
ScheduledAt = DateTime.UtcNow.AddDays(3), Status = FollowUpStatus.Upcoming,
|
||||||
|
});
|
||||||
|
db.FollowUps.Add(new FollowUp
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = user.Id, Title = "术后3周复查",
|
||||||
|
DoctorName = "王建国", Department = "心血管内科",
|
||||||
|
ScheduledAt = DateTime.UtcNow.AddDays(-14), Status = FollowUpStatus.Completed,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Console.WriteLine($"[DEV] 测试数据已填充:用户 {user.Phone}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Health.Domain\Health.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
|
<PackageReference Include="Minio" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
80
backend/src/Health.Infrastructure/Services/JwtProvider.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT Token 生成与验证服务
|
||||||
|
/// </summary>
|
||||||
|
public sealed class JwtProvider
|
||||||
|
{
|
||||||
|
private readonly string _secret;
|
||||||
|
private readonly string _issuer;
|
||||||
|
private readonly string _audience;
|
||||||
|
|
||||||
|
public JwtProvider(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_secret = configuration["JWT_SECRET"] ?? "dev-secret-key-change-in-production-min-32-chars!!";
|
||||||
|
_issuer = configuration["JWT_ISSUER"] ?? "health-manager";
|
||||||
|
_audience = configuration["JWT_AUDIENCE"] ?? "health-manager-app";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 access_token(30 分钟有效)
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateAccessToken(Guid userId, string phone)
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||||
|
new Claim(ClaimTypes.MobilePhone, phone),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
|
||||||
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _issuer,
|
||||||
|
audience: _audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(30),
|
||||||
|
signingCredentials: credentials
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 refresh_token(30 天有效)
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateRefreshToken()
|
||||||
|
{
|
||||||
|
var randomBytes = new byte[64];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(randomBytes);
|
||||||
|
return Convert.ToBase64String(randomBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 JWT token 并返回 ClaimsPrincipal
|
||||||
|
/// </summary>
|
||||||
|
public TokenValidationParameters GetValidationParameters()
|
||||||
|
{
|
||||||
|
return new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = _issuer,
|
||||||
|
ValidAudience = _audience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)),
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/Health.Infrastructure/Services/SmsService.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 短信验证码服务(开发阶段直接返回成功)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SmsService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 发送验证码(开发阶段不做真实发送)
|
||||||
|
/// </summary>
|
||||||
|
public Task<bool> SendCodeAsync(string phone, string code)
|
||||||
|
{
|
||||||
|
// 开发阶段:直接在控制台输出,不做真实发送
|
||||||
|
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}");
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 6 位随机数字验证码
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateCode()
|
||||||
|
{
|
||||||
|
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
|
||||||
|
return Random.Shared.Next(100000, 1000000).ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据清理后台服务(每小时检查一次)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CleanupService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<CleanupService> _logger;
|
||||||
|
|
||||||
|
public CleanupService(IServiceScopeFactory scopeFactory, ILogger<CleanupService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
// 清理 30 天前的对话记录
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||||
|
var oldConversations = await db.Conversations
|
||||||
|
.Where(c => c.CreatedAt < cutoff)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (oldConversations.Count > 0)
|
||||||
|
{
|
||||||
|
db.Conversations.RemoveRange(oldConversations);
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
_logger.LogInformation("清理 {Count} 条过期对话", oldConversations.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期验证码
|
||||||
|
var expiredCodes = await db.VerificationCodes
|
||||||
|
.Where(v => v.ExpiresAt < DateTime.UtcNow)
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (expiredCodes.Count > 0)
|
||||||
|
{
|
||||||
|
db.VerificationCodes.RemoveRange(expiredCodes);
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "数据清理异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用药提醒定时扫描服务(每分钟检查一次)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MedicationReminderService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<MedicationReminderService> _logger;
|
||||||
|
|
||||||
|
public MedicationReminderService(IServiceScopeFactory scopeFactory, ILogger<MedicationReminderService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("用药提醒服务已启动");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessReminders(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "用药提醒扫描异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessReminders(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
// 使用北京时间(UTC+8)
|
||||||
|
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||||
|
var beijingTime = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var today = DateOnly.FromDateTime(beijingNow);
|
||||||
|
|
||||||
|
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒)
|
||||||
|
var windowStart = beijingTime.AddMinutes(-5);
|
||||||
|
var medications = await db.Medications
|
||||||
|
.Where(m => m.IsActive)
|
||||||
|
.Where(m => m.TimeOfDay.Any(t => t >= windowStart && t <= beijingTime))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var med in medications)
|
||||||
|
{
|
||||||
|
// 检查今天是否已打卡
|
||||||
|
var alreadyLogged = await db.MedicationLogs
|
||||||
|
.AnyAsync(l => l.MedicationId == med.Id
|
||||||
|
&& l.CreatedAt.Date == beijingNow.Date
|
||||||
|
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||||
|
|
||||||
|
if (alreadyLogged) continue;
|
||||||
|
|
||||||
|
// TODO: 调用极光推送发送用药提醒
|
||||||
|
_logger.LogInformation("用药提醒: 用户 {UserId} 药品 {Name} {Dosage} 时间 {Time}",
|
||||||
|
med.UserId, med.Name, med.Dosage, beijingTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
572
backend/src/Health.WebApi/Endpoints/AiChatEndpoints.cs
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 对话 SSE 端点——支持 7 个 Agent
|
||||||
|
/// </summary>
|
||||||
|
public static class AiChatEndpoints
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void MapAiChatEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// SSE 流式对话(GET 方式,token 通过 query string 传递)
|
||||||
|
app.MapGet("/api/ai/{agentType}/chat", async (
|
||||||
|
string message,
|
||||||
|
string? conversationId,
|
||||||
|
string token,
|
||||||
|
string agentType,
|
||||||
|
HttpContext http,
|
||||||
|
AppDbContext db,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
PromptManager promptManager,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||||
|
var userId = GetUserId(http) ?? GetUserIdFromToken(token);
|
||||||
|
if (userId == null)
|
||||||
|
{
|
||||||
|
http.Response.StatusCode = 401;
|
||||||
|
http.Response.ContentType = "application/json";
|
||||||
|
await http.Response.WriteAsync(JsonSerializer.Serialize(new { code = 40002, data = (object?)null, message = "未登录" }), ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<AgentType>(agentType, ignoreCase: true, out var parsedType))
|
||||||
|
parsedType = AgentType.Default;
|
||||||
|
|
||||||
|
// SSE 响应头
|
||||||
|
http.Response.ContentType = "text/event-stream";
|
||||||
|
http.Response.Headers.CacheControl = "no-cache";
|
||||||
|
http.Response.Headers.Connection = "keep-alive";
|
||||||
|
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
|
||||||
|
// 创建或获取对话
|
||||||
|
Conversation? conversation = null;
|
||||||
|
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId))
|
||||||
|
conversation = await db.Conversations.FindAsync([convId], ct);
|
||||||
|
|
||||||
|
if (conversation == null)
|
||||||
|
{
|
||||||
|
conversation = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType,
|
||||||
|
Title = message.Length > 30 ? message[..30] : message,
|
||||||
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Conversations.Add(conversation);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存用户消息
|
||||||
|
var userMsg = new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User,
|
||||||
|
Content = message, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.ConversationMessages.Add(userMsg);
|
||||||
|
conversation.MessageCount++;
|
||||||
|
conversation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 加载上下文
|
||||||
|
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
||||||
|
var patientContext = await BuildPatientContext(db, userId.Value, ct);
|
||||||
|
|
||||||
|
var messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "system", Content = systemPrompt + "\n\n当前患者信息:\n" + patientContext },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载历史对话(最近 10 条)
|
||||||
|
var history = await db.ConversationMessages
|
||||||
|
.Where(m => m.ConversationId == conversation.Id)
|
||||||
|
.OrderByDescending(m => m.CreatedAt)
|
||||||
|
.Take(12)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var h in history.Reverse<ConversationMessage>())
|
||||||
|
{
|
||||||
|
messages.Add(new ChatMessage
|
||||||
|
{
|
||||||
|
Role = h.Role == MessageRole.User ? "user" : "assistant",
|
||||||
|
Content = h.Content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool Calling 循环
|
||||||
|
var tools = GetToolsForAgent(parsedType);
|
||||||
|
var maxIterations = 5;
|
||||||
|
var fullResponse = "";
|
||||||
|
var completedNormally = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxIterations; i++)
|
||||||
|
{
|
||||||
|
await SseWriteAsync(http, new { action = "notice", message = i == 0 ? "正在分析..." : "正在处理..." }, ct);
|
||||||
|
|
||||||
|
var response = await llmClient.ChatAsync(messages, tools: tools.Count > 0 ? tools : null, ct: ct);
|
||||||
|
|
||||||
|
var choice = response.Choices?.FirstOrDefault();
|
||||||
|
if (choice == null) break;
|
||||||
|
|
||||||
|
if (choice.FinishReason == "stop")
|
||||||
|
{
|
||||||
|
// 流式输出最终回复(带上完整的 tool call 历史,方便 LLM 利用工具结果生成回复)
|
||||||
|
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var delta = JsonSerializer.Deserialize<ChatCompletionResponse>(chunk, JsonOpts);
|
||||||
|
var content = delta?.Choices?.FirstOrDefault()?.Delta?.Content;
|
||||||
|
if (!string.IsNullOrEmpty(content))
|
||||||
|
{
|
||||||
|
fullResponse += content;
|
||||||
|
await SseWriteAsync(http, new { action = "answer", data = content }, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 跳过解析失败的 chunk */ }
|
||||||
|
}
|
||||||
|
completedNormally = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
|
||||||
|
{
|
||||||
|
// 一条 assistant 消息包含所有 tool calls(符合 OpenAI 协议)
|
||||||
|
messages.Add(new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "assistant",
|
||||||
|
Content = choice.Message.Content ?? "",
|
||||||
|
ToolCalls = choice.Message.ToolCalls,
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var tc in choice.Message.ToolCalls)
|
||||||
|
{
|
||||||
|
object toolResult;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
toolResult = new { success = false, message = $"工具执行异常: {ex.Message}" };
|
||||||
|
}
|
||||||
|
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
|
||||||
|
|
||||||
|
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 AI 回复
|
||||||
|
if (!string.IsNullOrEmpty(fullResponse))
|
||||||
|
{
|
||||||
|
db.ConversationMessages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant,
|
||||||
|
Content = fullResponse, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
conversation.MessageCount++;
|
||||||
|
conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse;
|
||||||
|
conversation.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||||
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取对话列表
|
||||||
|
app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var conversations = await db.Conversations
|
||||||
|
.Where(c => c.UserId == userId.Value)
|
||||||
|
.OrderByDescending(c => c.UpdatedAt)
|
||||||
|
.Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = conversations, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取对话历史
|
||||||
|
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
|
var messages = await db.ConversationMessages
|
||||||
|
.Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value)
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除对话
|
||||||
|
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
|
var conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct);
|
||||||
|
if (conv != null)
|
||||||
|
{
|
||||||
|
db.Conversations.Remove(conv);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// VLM 食物识别
|
||||||
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
|
HttpRequest httpRequest, HttpContext http,
|
||||||
|
QwenVisionClient visionClient, AppDbContext db,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
|
var form = await httpRequest.ReadFormAsync(ct);
|
||||||
|
var files = form.Files.GetFiles("images");
|
||||||
|
if (files == null || files.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
|
||||||
|
|
||||||
|
var imageUrls = new List<string>();
|
||||||
|
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (file.Length > 10 * 1024 * 1024)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 10MB 限制" });
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
|
if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic")
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" });
|
||||||
|
|
||||||
|
var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
|
||||||
|
var filePath = Path.Combine(uploadsDir, safeName);
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Create);
|
||||||
|
await file.CopyToAsync(stream, ct);
|
||||||
|
imageUrls.Add($"file://{filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = """
|
||||||
|
识别图片中的所有食物,返回 JSON 格式:
|
||||||
|
{
|
||||||
|
"foods": [{"name":"食物名","portion":"份量描述","calories":数字,"proteinGrams":数字,"carbsGrams":数字,"fatGrams":数字,"warning":null或警告文字}],
|
||||||
|
"totalCalories":总热量数字,
|
||||||
|
"warnings":["整体警告"],
|
||||||
|
"score":1-5评分
|
||||||
|
}
|
||||||
|
请只返回 JSON,不要加任何其他文字。
|
||||||
|
""";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await visionClient.VisionAsync(prompt, imageUrls, ct: ct);
|
||||||
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败,请重试" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(data, JsonOpts);
|
||||||
|
await http.Response.WriteAsync($"data: {json}\n\n", ct);
|
||||||
|
await http.Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
|
||||||
|
|
||||||
|
/// 从 query string token 解析用户 ID(浏览器 EventSource 用)
|
||||||
|
private static Guid? GetUserIdFromToken(string? token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
|
||||||
|
var jwt = handler.ReadJwtToken(token);
|
||||||
|
var sub = jwt.Claims.FirstOrDefault(c => c.Type == System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
return sub != null && Guid.TryParse(sub, out var id) ? id : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
||||||
|
{
|
||||||
|
AgentType.Health => [RecordHealthDataTool, QueryHealthRecordsTool],
|
||||||
|
AgentType.Medication => [ManageMedicationTool, CheckArchiveTool],
|
||||||
|
AgentType.Diet => [EstimateFoodTool, CheckArchiveTool],
|
||||||
|
AgentType.Consultation => [QueryHealthRecordsTool, CheckArchiveTool, RequestDoctorTool],
|
||||||
|
AgentType.Report => [AnalyzeReportTool, QueryHealthRecordsTool],
|
||||||
|
AgentType.Exercise => [ManageExerciseTool],
|
||||||
|
_ => [QueryHealthRecordsTool, CheckArchiveTool],
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||||
|
var root = jsonDoc.RootElement;
|
||||||
|
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"record_health_data" => await ExecuteRecordHealthData(db, userId, root),
|
||||||
|
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, root),
|
||||||
|
"check_archive" => await ExecuteCheckArchive(db, userId),
|
||||||
|
"manage_medication" => await ExecuteManageMedication(db, userId, root),
|
||||||
|
"manage_exercise" => await ExecuteManageExercise(db, userId, root),
|
||||||
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||||
|
var record = new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
||||||
|
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "blood_pressure":
|
||||||
|
record.MetricType = HealthMetricType.BloodPressure;
|
||||||
|
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
||||||
|
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
||||||
|
record.Unit = "mmHg";
|
||||||
|
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
||||||
|
break;
|
||||||
|
case "heart_rate":
|
||||||
|
record.MetricType = HealthMetricType.HeartRate;
|
||||||
|
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
||||||
|
record.Unit = "次/分";
|
||||||
|
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
||||||
|
break;
|
||||||
|
case "glucose":
|
||||||
|
record.MetricType = HealthMetricType.Glucose;
|
||||||
|
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
||||||
|
record.Unit = "mmol/L";
|
||||||
|
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
||||||
|
break;
|
||||||
|
case "spo2":
|
||||||
|
record.MetricType = HealthMetricType.SpO2;
|
||||||
|
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
||||||
|
record.Unit = "%";
|
||||||
|
record.IsAbnormal = record.Value <= 94;
|
||||||
|
break;
|
||||||
|
case "weight":
|
||||||
|
record.MetricType = HealthMetricType.Weight;
|
||||||
|
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||||
|
record.Unit = "kg";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new { success = false, message = $"未知指标类型: {type}" };
|
||||||
|
}
|
||||||
|
|
||||||
|
db.HealthRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true, record_id = record.Id, type = record.MetricType.ToString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||||
|
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||||
|
|
||||||
|
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
||||||
|
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||||
|
query = query.Where(r => r.MetricType == mt);
|
||||||
|
|
||||||
|
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
|
||||||
|
|
||||||
|
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
|
||||||
|
{
|
||||||
|
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
return new { count = records.Count, records };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
|
||||||
|
if (archive == null) return new { found = false };
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||||
|
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||||
|
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
|
return action switch
|
||||||
|
{
|
||||||
|
"create" => await CreateMedication(db, userId, args),
|
||||||
|
"query" => await QueryMedications(db, userId),
|
||||||
|
"confirm" => await ConfirmMedication(db, userId, args),
|
||||||
|
_ => new { success = false, message = $"未知操作: {action}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var med = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId,
|
||||||
|
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
||||||
|
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
|
Source = MedicationSource.AiEntry, IsActive = true,
|
||||||
|
};
|
||||||
|
db.Medications.Add(med);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true, medication_id = med.Id, med.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
||||||
|
return new { count = meds.Count, medications = meds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||||
|
db.MedicationLogs.Add(new MedicationLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
||||||
|
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
|
if (action != "query") return new { success = false, message = "运动计划管理暂未实现" };
|
||||||
|
|
||||||
|
var plan = await db.ExercisePlans.Where(p => p.UserId == userId)
|
||||||
|
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
|
||||||
|
if (plan == null) return new { found = false };
|
||||||
|
var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
|
||||||
|
return new { found = true, plan_id = plan.Id, items = items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||||
|
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId)
|
||||||
|
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
if (archive != null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||||
|
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||||
|
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
||||||
|
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||||
|
}
|
||||||
|
if (recentRecords.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("近期健康数据:");
|
||||||
|
foreach (var r in recentRecords)
|
||||||
|
sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RecordValue(HealthRecord r) => r.MetricType switch
|
||||||
|
{
|
||||||
|
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}",
|
||||||
|
HealthMetricType.HeartRate => $"{r.Value}次/分",
|
||||||
|
HealthMetricType.Glucose => $"{r.Value}",
|
||||||
|
HealthMetricType.SpO2 => $"{r.Value}%",
|
||||||
|
HealthMetricType.Weight => $"{r.Value}kg",
|
||||||
|
_ => "—"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Tool Definitions ----
|
||||||
|
private static readonly ToolDefinition RecordHealthDataTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||||
|
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition QueryHealthRecordsTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "query_health_records", Description = "查询近期健康数据",
|
||||||
|
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition CheckArchiveTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition ManageMedicationTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "manage_medication", Description = "用药管理",
|
||||||
|
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition ManageExerciseTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "manage_exercise", Description = "运动计划管理",
|
||||||
|
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition EstimateFoodTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition AnalyzeReportTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "analyze_report", Description = "分析报告图片", Parameters = new { type = "object", properties = new { image_url = new { type = "string" } }, required = new[] { "image_url" } } }
|
||||||
|
};
|
||||||
|
private static readonly ToolDefinition RequestDoctorTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "request_doctor", Description = "请求转接真人医生",
|
||||||
|
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AI 对话请求</summary>
|
||||||
|
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||||
190
backend/src/Health.WebApi/Endpoints/AuthEndpoints.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Health.Infrastructure.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 认证相关 API 端点
|
||||||
|
/// </summary>
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// 发送短信验证码
|
||||||
|
app.MapPost("/api/auth/send-sms", async (
|
||||||
|
SendSmsRequest request,
|
||||||
|
AppDbContext db,
|
||||||
|
SmsService sms,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
// 生成验证码
|
||||||
|
var code = sms.GenerateCode();
|
||||||
|
var vc = new VerificationCode
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = request.Phone,
|
||||||
|
Code = code,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
};
|
||||||
|
db.VerificationCodes.Add(vc);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 开发阶段:直接返回验证码(生产环境需去掉 devCode)
|
||||||
|
await sms.SendCodeAsync(request.Phone, code);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 手机号+验证码登录
|
||||||
|
app.MapPost("/api/auth/login", async (
|
||||||
|
LoginRequest request,
|
||||||
|
AppDbContext db,
|
||||||
|
JwtProvider jwt,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
// 开发阶段:任意6位数字通过
|
||||||
|
var validCode = await db.VerificationCodes
|
||||||
|
.Where(v => v.Phone == request.Phone
|
||||||
|
&& v.Code == request.SmsCode
|
||||||
|
&& v.ExpiresAt > DateTime.UtcNow
|
||||||
|
&& !v.IsUsed)
|
||||||
|
.OrderByDescending(v => v.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (validCode == null)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" });
|
||||||
|
|
||||||
|
validCode.IsUsed = true;
|
||||||
|
|
||||||
|
// 查找或创建用户
|
||||||
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||||
|
var isNew = false;
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = request.Phone,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
isNew = true;
|
||||||
|
|
||||||
|
// 创建默认通知偏好
|
||||||
|
db.NotificationPreferences.Add(new NotificationPreference
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建空健康档案
|
||||||
|
db.HealthArchives.Add(new HealthArchive
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// 生成 token
|
||||||
|
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone);
|
||||||
|
var refreshToken = jwt.GenerateRefreshToken();
|
||||||
|
|
||||||
|
// 保存 refresh token
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = refreshToken,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user = new
|
||||||
|
{
|
||||||
|
user.Id,
|
||||||
|
user.Phone,
|
||||||
|
user.Name,
|
||||||
|
user.Gender,
|
||||||
|
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||||
|
user.AvatarUrl,
|
||||||
|
isNew
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 刷新 token
|
||||||
|
app.MapPost("/api/auth/refresh", async (
|
||||||
|
RefreshRequest request,
|
||||||
|
AppDbContext db,
|
||||||
|
JwtProvider jwt,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var oldToken = await db.RefreshTokens
|
||||||
|
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && !t.IsRevoked, ct);
|
||||||
|
|
||||||
|
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow)
|
||||||
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" });
|
||||||
|
|
||||||
|
// 吊销旧 token
|
||||||
|
oldToken.IsRevoked = true;
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync([oldToken.UserId], ct);
|
||||||
|
if (user == null)
|
||||||
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
||||||
|
|
||||||
|
// 生成新 token(续期)
|
||||||
|
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone);
|
||||||
|
var newRefreshToken = jwt.GenerateRefreshToken();
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
Token = newRefreshToken,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new { accessToken, refreshToken = newRefreshToken },
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
app.MapPost("/api/auth/logout", async (
|
||||||
|
RefreshRequest request,
|
||||||
|
AppDbContext db,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var token = await db.RefreshTokens
|
||||||
|
.FirstOrDefaultAsync(t => t.Token == request.RefreshToken, ct);
|
||||||
|
if (token != null) token.IsRevoked = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 请求 DTO ----
|
||||||
|
public sealed record SendSmsRequest(string Phone);
|
||||||
|
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||||
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
132
backend/src/Health.WebApi/Endpoints/HealthEndpoints.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康数据 API 端点
|
||||||
|
/// </summary>
|
||||||
|
public static class HealthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapHealthEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/health-records").RequireAuthorization();
|
||||||
|
|
||||||
|
// 查询健康记录
|
||||||
|
group.MapGet("/", async (
|
||||||
|
string? type, int? days,
|
||||||
|
HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||||
|
query = query.Where(r => r.MetricType == mt);
|
||||||
|
|
||||||
|
if (days.HasValue)
|
||||||
|
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days.Value));
|
||||||
|
|
||||||
|
var records = await query.OrderByDescending(r => r.RecordedAt).Take(100)
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit,
|
||||||
|
Source = r.Source.ToString(), r.IsAbnormal, r.RecordedAt
|
||||||
|
}).ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 新增健康记录
|
||||||
|
group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, MetricType = req.Type,
|
||||||
|
Systolic = req.Systolic, Diastolic = req.Diastolic, Value = req.Value,
|
||||||
|
Unit = req.Unit, Source = req.Source, RecordedAt = req.RecordedAt ?? DateTime.UtcNow,
|
||||||
|
IsAbnormal = CheckAbnormal(req),
|
||||||
|
};
|
||||||
|
db.HealthRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改健康记录
|
||||||
|
group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = await db.HealthRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||||
|
if (record == null) return Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
||||||
|
|
||||||
|
record.Systolic = req.Systolic;
|
||||||
|
record.Diastolic = req.Diastolic;
|
||||||
|
record.Value = req.Value;
|
||||||
|
record.Unit = req.Unit;
|
||||||
|
record.RecordedAt = req.RecordedAt ?? record.RecordedAt;
|
||||||
|
record.IsAbnormal = CheckAbnormal(req);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取各指标最新值
|
||||||
|
group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var types = new[] { HealthMetricType.BloodPressure, HealthMetricType.HeartRate, HealthMetricType.Glucose, HealthMetricType.SpO2, HealthMetricType.Weight };
|
||||||
|
var result = new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
foreach (var t in types)
|
||||||
|
{
|
||||||
|
var latest = await db.HealthRecords
|
||||||
|
.Where(r => r.UserId == userId && r.MetricType == t)
|
||||||
|
.OrderByDescending(r => r.RecordedAt)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
result[t.ToString()] = latest == null ? null : new
|
||||||
|
{
|
||||||
|
latest.Systolic, latest.Diastolic, latest.Value, latest.Unit, latest.RecordedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 趋势数据
|
||||||
|
group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的指标类型" });
|
||||||
|
|
||||||
|
var days = period switch { 7 => 7, 30 => 30, 90 => 90, _ => 7 };
|
||||||
|
var records = await db.HealthRecords
|
||||||
|
.Where(r => r.UserId == userId && r.MetricType == mt && r.RecordedAt >= DateTime.UtcNow.AddDays(-days))
|
||||||
|
.OrderBy(r => r.RecordedAt)
|
||||||
|
.Select(r => new { r.Id, r.Systolic, r.Diastolic, r.Value, r.IsAbnormal, r.RecordedAt })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CheckAbnormal(CreateHealthRecordRequest req) => req.Type switch
|
||||||
|
{
|
||||||
|
HealthMetricType.BloodPressure => req.Systolic >= 140 || req.Diastolic >= 90 || req.Systolic <= 89 || req.Diastolic <= 59,
|
||||||
|
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
|
||||||
|
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
|
||||||
|
HealthMetricType.SpO2 => req.Value <= 94,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateHealthRecordRequest(
|
||||||
|
HealthMetricType Type, int? Systolic, int? Diastolic, decimal? Value,
|
||||||
|
string? Unit, HealthRecordSource Source, DateTime? RecordedAt);
|
||||||
266
backend/src/Health.WebApi/Endpoints/RemainingEndpoints.cs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 饮食、用药、报告、问诊、运动、文件端点
|
||||||
|
/// </summary>
|
||||||
|
public static class RemainingEndpoints
|
||||||
|
{
|
||||||
|
public static void MapDietEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
||||||
|
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
||||||
|
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
||||||
|
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = new DietRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
|
||||||
|
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
|
||||||
|
};
|
||||||
|
if (req.FoodItems != null)
|
||||||
|
foreach (var fi in req.FoodItems)
|
||||||
|
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
|
||||||
|
db.DietRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||||
|
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapMedicationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
|
||||||
|
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
|
||||||
|
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
|
||||||
|
};
|
||||||
|
db.Medications.Add(med);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||||
|
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
|
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
|
||||||
|
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
|
||||||
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||||
|
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var log = new MedicationLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||||
|
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.MedicationLogs.Add(log);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapReportEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||||
|
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapConsultationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/doctors", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
|
||||||
|
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var consultations = await db.Consultations.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).ToListAsync();
|
||||||
|
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/consultations", async (CreateConsultationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var consultation = new Consultation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, DoctorId = req.DoctorId,
|
||||||
|
Status = ConsultationStatus.AiTalking,
|
||||||
|
Month = DateTime.UtcNow.Year * 100 + DateTime.UtcNow.Month,
|
||||||
|
};
|
||||||
|
db.Consultations.Add(consultation);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { consultation.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, string? after, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var query = db.ConsultationMessages.Where(m => m.ConsultationId == id && m.Consultation.UserId == userId);
|
||||||
|
if (Guid.TryParse(after, out var afterId))
|
||||||
|
query = query.Where(m => m.Id.CompareTo(afterId) > 0);
|
||||||
|
var messages = await query.OrderBy(m => m.CreatedAt).Take(50).Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||||
|
db.ConsultationMessages.Add(msg);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/user/consultation-quota", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// 用年月组合值避免跨年问题:202601=2026年1月
|
||||||
|
var currentPeriod = now.Year * 100 + now.Month;
|
||||||
|
var used = await db.Consultations.CountAsync(c => c.UserId == userId && c.Month == currentPeriod);
|
||||||
|
return Results.Ok(new { code = 0, data = new { total = 3, used, remaining = 3 - used }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapExerciseEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
|
||||||
|
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
|
||||||
|
return Results.Ok(new { code = 0, data = plan, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
|
||||||
|
if (req.Items != null)
|
||||||
|
foreach (var item in req.Items)
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
||||||
|
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
|
item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void MapFileEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/upload", async (HttpRequest request) =>
|
||||||
|
{
|
||||||
|
var form = await request.ReadFormAsync();
|
||||||
|
var files = form.Files;
|
||||||
|
var results = new List<object>();
|
||||||
|
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileId = Guid.NewGuid().ToString();
|
||||||
|
var ext = Path.GetExtension(file.FileName);
|
||||||
|
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Create);
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 请求 DTO ----
|
||||||
|
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
|
||||||
|
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);
|
||||||
|
|
||||||
|
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);
|
||||||
|
|
||||||
|
public sealed record CreateConsultationRequest(Guid DoctorId);
|
||||||
|
public sealed record SendMessageRequest(string Content);
|
||||||
|
|
||||||
|
public sealed record CreateExercisePlanRequest(DateOnly WeekStartDate, List<ExerciseItemDto>? Items);
|
||||||
|
public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay);
|
||||||
89
backend/src/Health.WebApi/Endpoints/UserEndpoints.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户与健康档案 API 端点
|
||||||
|
/// </summary>
|
||||||
|
public static class UserEndpoints
|
||||||
|
{
|
||||||
|
public static void MapUserEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/user").RequireAuthorization();
|
||||||
|
|
||||||
|
// 获取个人信息
|
||||||
|
group.MapGet("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var user = await db.Users.Select(u => new
|
||||||
|
{
|
||||||
|
u.Id, u.Phone, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl
|
||||||
|
}).FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = user, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改资料
|
||||||
|
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var user = await db.Users.FindAsync([userId], ct);
|
||||||
|
if (user == null) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||||
|
|
||||||
|
user.Name = req.Name ?? user.Name;
|
||||||
|
user.Gender = req.Gender ?? user.Gender;
|
||||||
|
if (DateOnly.TryParse(req.BirthDate, out var bd)) user.BirthDate = bd;
|
||||||
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取健康档案
|
||||||
|
group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = archive, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新健康档案
|
||||||
|
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||||
|
if (archive == null) return Results.Ok(new { code = 40004, message = "档案不存在" });
|
||||||
|
|
||||||
|
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis;
|
||||||
|
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType;
|
||||||
|
if (DateOnly.TryParse(req.SurgeryDate, out var sd)) archive.SurgeryDate = sd;
|
||||||
|
if (req.Allergies != null) archive.Allergies = req.Allergies;
|
||||||
|
if (req.DietRestrictions != null) archive.DietRestrictions = req.DietRestrictions;
|
||||||
|
if (req.ChronicDiseases != null) archive.ChronicDiseases = req.ChronicDiseases;
|
||||||
|
archive.FamilyHistory = req.FamilyHistory ?? archive.FamilyHistory;
|
||||||
|
archive.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注销账号
|
||||||
|
group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var user = await db.Users.FindAsync([userId], ct);
|
||||||
|
if (user != null) { db.Users.Remove(user); await db.SaveChangesAsync(ct); }
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
|
||||||
|
public sealed record UpdateArchiveRequest(
|
||||||
|
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||||
|
List<string>? Allergies, List<string>? DietRestrictions,
|
||||||
|
List<string>? ChronicDiseases, string? FamilyHistory);
|
||||||
20
backend/src/Health.WebApi/Health.WebApi.csproj
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<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.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Health.Application\Health.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Health.Infrastructure\Health.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
backend/src/Health.WebApi/Health.WebApi.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@Health.WebApi_HostAddress = http://localhost:5277
|
||||||
|
|
||||||
|
GET {{Health.WebApi_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
42
backend/src/Health.WebApi/Middleware/ExceptionMiddleware.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全局异常处理中间件——统一返回 {code, data, message} 格式
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExceptionMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionMiddleware> _logger;
|
||||||
|
|
||||||
|
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 生产环境不暴露内部异常详情
|
||||||
|
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var result = new
|
||||||
|
{
|
||||||
|
code = 50000,
|
||||||
|
data = (object?)null,
|
||||||
|
message = "服务器内部错误,请稍后重试"
|
||||||
|
};
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
backend/src/Health.WebApi/Program.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Infrastructure.Data;
|
||||||
|
using Health.Infrastructure.Services;
|
||||||
|
using Health.WebApi.BackgroundServices;
|
||||||
|
using Health.WebApi.Endpoints;
|
||||||
|
using Health.WebApi.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
// 加载 .env 文件(开发环境)
|
||||||
|
var envPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", ".env");
|
||||||
|
if (File.Exists(envPath))
|
||||||
|
{
|
||||||
|
foreach (var line in File.ReadAllLines(envPath))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')) continue;
|
||||||
|
var eqIdx = trimmed.IndexOf('=');
|
||||||
|
if (eqIdx <= 0) continue;
|
||||||
|
var key = trimmed[..eqIdx].Trim();
|
||||||
|
var value = trimmed[(eqIdx + 1)..].Trim();
|
||||||
|
Environment.SetEnvironmentVariable(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ---- 数据库 ----
|
||||||
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("Default") ?? builder.Configuration["DB_CONNECTION"] ?? "Host=localhost;Database=health_manager;Username=postgres;Password=postgres"));
|
||||||
|
|
||||||
|
// ---- JWT 认证 ----
|
||||||
|
var jwtSecret = builder.Configuration["JWT_SECRET"];
|
||||||
|
if (string.IsNullOrEmpty(jwtSecret) && !builder.Environment.IsDevelopment())
|
||||||
|
throw new InvalidOperationException("JWT_SECRET 环境变量未配置,生产环境必须设置");
|
||||||
|
jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = builder.Configuration["JWT_ISSUER"] ?? "health-manager",
|
||||||
|
ValidAudience = builder.Configuration["JWT_AUDIENCE"] ?? "health-manager-app",
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
});
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// ---- 业务服务 ----
|
||||||
|
builder.Services.AddSingleton<JwtProvider>();
|
||||||
|
builder.Services.AddSingleton<SmsService>();
|
||||||
|
builder.Services.AddSingleton<PromptManager>();
|
||||||
|
|
||||||
|
// ---- AI 客户端(使用 IHttpClientFactory 区分 LLM 和 VLM)----
|
||||||
|
builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri((builder.Configuration["DEEPSEEK_BASE_URL"] ?? "https://api.deepseek.com/v1").TrimEnd('/') + "/");
|
||||||
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
|
});
|
||||||
|
builder.Services.AddHttpClient<QwenVisionClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri((builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
||||||
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["QWEN_API_KEY"] ?? "");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 后台服务 ----
|
||||||
|
builder.Services.AddHostedService<MedicationReminderService>();
|
||||||
|
builder.Services.AddHostedService<CleanupService>();
|
||||||
|
|
||||||
|
// ---- OpenAPI ----
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// ---- CORS ----
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
|
||||||
|
});
|
||||||
|
// 生产环境:policy.WithOrigins("https://yourdomain.com").AllowAnyMethod().AllowAnyHeader();
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ---- 中间件管道(ExceptionMiddleware 放最前面)----
|
||||||
|
app.UseMiddleware<ExceptionMiddleware>();
|
||||||
|
app.UseCors();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
// ---- 初始化数据库(开发环境:每次重建)----
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await DataSeeder.SeedAsync(db);
|
||||||
|
await DevDataSeeder.SeedIfEnabled(db, app.Configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 注册 API 端点 ----
|
||||||
|
app.MapAuthEndpoints();
|
||||||
|
app.MapHealthEndpoints();
|
||||||
|
app.MapDietEndpoints();
|
||||||
|
app.MapMedicationEndpoints();
|
||||||
|
app.MapReportEndpoints();
|
||||||
|
app.MapConsultationEndpoints();
|
||||||
|
app.MapExerciseEndpoints();
|
||||||
|
app.MapUserEndpoints();
|
||||||
|
app.MapAiChatEndpoints();
|
||||||
|
app.MapFileEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
backend/src/Health.WebApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5277",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7102;http://localhost:5277",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/Health.WebApi/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/Health.WebApi/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Default": "Host=localhost;Database=health_manager;Username=postgres;Password=postgres123"
|
||||||
|
},
|
||||||
|
"JWT_SECRET": "dev-secret-key-change-in-production-min-32-chars!!",
|
||||||
|
"JWT_ISSUER": "health-manager",
|
||||||
|
"JWT_AUDIENCE": "health-manager-app",
|
||||||
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||||
|
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
||||||
|
"DEEPSEEK_MODEL": "deepseek-chat",
|
||||||
|
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"QWEN_API_KEY": "sk-your-key-here",
|
||||||
|
"QWEN_VISION_MODEL": "qwen-vl-max",
|
||||||
|
"MINIO_ENDPOINT": "localhost:9000",
|
||||||
|
"MINIO_ACCESS_KEY": "minioadmin",
|
||||||
|
"MINIO_SECRET_KEY": "minioadmin123"
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public class UnitTest1
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL 数据库
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: health_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: health_manager
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres123
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MinIO 对象存储
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: health_minio
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin123
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API
|
||||||
|
- "9001:9001" # Web 控制台
|
||||||
|
volumes:
|
||||||
|
- miniodata:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
miniodata:
|
||||||
45
health_app/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
30
health_app/.metadata
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
|
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
|
- platform: web
|
||||||
|
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
|
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
16
health_app/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# health_app
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
28
health_app/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
14
health_app/android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
44
health_app/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.healthmanager.health_app"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.healthmanager.health_app"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
health_app/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
health_app/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="health_app"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.healthmanager.health_app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
BIN
health_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
health_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
health_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
18
health_app/android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
18
health_app/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
7
health_app/android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
26
health_app/android/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
3
health_app/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 -Duser.language=en -Duser.country=US
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.overridePathCheck=true
|
||||||
5
health_app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||||
22
health_app/android/settings.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath = "C:/flutter_sdk"
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.11.1" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include(":app")
|
||||||
34
health_app/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
**/dgph
|
||||||
|
*.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
*.moved-aside
|
||||||
|
*.pbxuser
|
||||||
|
*.perspectivev3
|
||||||
|
**/*sync/
|
||||||
|
.sconsign.dblite
|
||||||
|
.tags*
|
||||||
|
**/.vagrant/
|
||||||
|
**/DerivedData/
|
||||||
|
Icon?
|
||||||
|
**/Pods/
|
||||||
|
**/.symlinks/
|
||||||
|
profile
|
||||||
|
xcuserdata
|
||||||
|
**/.generated/
|
||||||
|
Flutter/App.framework
|
||||||
|
Flutter/Flutter.framework
|
||||||
|
Flutter/Flutter.podspec
|
||||||
|
Flutter/Generated.xcconfig
|
||||||
|
Flutter/ephemeral/
|
||||||
|
Flutter/app.flx
|
||||||
|
Flutter/app.zip
|
||||||
|
Flutter/flutter_assets/
|
||||||
|
Flutter/flutter_export_environment.sh
|
||||||
|
ServiceDefinitions.json
|
||||||
|
Runner/GeneratedPluginRegistrant.*
|
||||||
|
|
||||||
|
# Exceptions to above rules.
|
||||||
|
!default.mode1v3
|
||||||
|
!default.mode2v3
|
||||||
|
!default.pbxuser
|
||||||
|
!default.perspectivev3
|
||||||
26
health_app/ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.flutter.flutter.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>App</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>MinimumOSVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
health_app/ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
1
health_app/ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#include "Generated.xcconfig"
|
||||||
616
health_app/ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 54;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
|
remoteInfo = Runner;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Embed Frameworks";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
);
|
||||||
|
path = RunnerTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Flutter;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146E51CF9000F007C117D = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146EF1CF9000F007C117D /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
|
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||||
|
97C147021CF9000F007C117D /* Info.plist */,
|
||||||
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
);
|
||||||
|
path = Runner;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = RunnerTests;
|
||||||
|
productName = RunnerTests;
|
||||||
|
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
|
buildPhases = (
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = Runner;
|
||||||
|
productName = Runner;
|
||||||
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1510;
|
||||||
|
ORGANIZATIONNAME = "";
|
||||||
|
TargetAttributes = {
|
||||||
|
331C8080294A63A400263BE5 = {
|
||||||
|
CreatedOnToolsVersion = 14.0;
|
||||||
|
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||||
|
};
|
||||||
|
97C146ED1CF9000F007C117D = {
|
||||||
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
|
LastSwiftMigration = 1100;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||||
|
compatibilityVersion = "Xcode 9.3";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
331C807F294A63A400263BE5 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||||
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
|
};
|
||||||
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
331C807D294A63A400263BE5 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXVariantGroup section */
|
||||||
|
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C146FB1CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = Main.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||||
|
isa = PBXVariantGroup;
|
||||||
|
children = (
|
||||||
|
97C147001CF9000F007C117D /* Base */,
|
||||||
|
);
|
||||||
|
name = LaunchScreen.storyboard;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXVariantGroup section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp.RunnerTests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147041CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
97C147061CF9000F007C117D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
97C147071CF9000F007C117D /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
ENABLE_BITCODE = NO;
|
||||||
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.healthmanager.healthApp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
331C8088294A63A400263BE5 /* Debug */,
|
||||||
|
331C8089294A63A400263BE5 /* Release */,
|
||||||
|
331C808A294A63A400263BE5 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147031CF9000F007C117D /* Debug */,
|
||||||
|
97C147041CF9000F007C117D /* Release */,
|
||||||
|
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
97C147061CF9000F007C117D /* Debug */,
|
||||||
|
97C147071CF9000F007C117D /* Release */,
|
||||||
|
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
}
|
||||||
7
health_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
7
health_app/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>IDEDidComputeMac32BitWarning</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
13
health_app/ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "Icon-App-1024x1024@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
health_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
health_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
health_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
health_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |