Initial commit: 健康管家 AI 健康陪伴助手

- Backend: .NET 10 Minimal API + EF Core + PostgreSQL
- Frontend: Flutter + Riverpod + GoRouter + Dio
- AI: DeepSeek LLM + Qwen VLM (OpenAI-compatible)
- Auth: SMS + JWT (access/refresh tokens)
- Features: AI chat, health tracking, medication management, diet analysis, exercise plans, doctor consultations, report analysis
This commit is contained in:
MingNian
2026-06-02 11:11:29 +08:00
commit 14d7c30d3d
144 changed files with 11436 additions and 0 deletions

37
.gitignore vendored Normal file
View 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
View 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
View 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

View 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>

View 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>

View 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; } = [];
}

View 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!;
}

View 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!;
}

View 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!;
}

View 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!;
}

View 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!;
}

View 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!;
}

View 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!;
}

View 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; }
}

View 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 // 工具返回
}

View File

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

View File

@@ -0,0 +1,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)!;
}
}

View 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 CompletionsSSE
/// </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

View 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.
""";
}

View 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();
});
}
}

View 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();
}
}
}

View 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}");
}
}

View File

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

View 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_token30 分钟有效)
/// </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_token30 天有效)
/// </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
};
}
}

View 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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View 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);

View 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);

View 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);

View 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);

View 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);

View 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>

View File

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

View 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));
}
}
}

View 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();

View 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"
}
}
}
}

View File

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

View 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"
}

View 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);
}
}

View 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);
}
}

View 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>

View File

@@ -0,0 +1,10 @@
namespace Health.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

35
docker-compose.yml Normal file
View 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
View 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
View 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
View 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.

View 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
View 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

View 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 = "../.."
}

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
package com.healthmanager.health_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

View 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>

View 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)
}

View 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

View 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

View 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
View 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

View 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>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

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

View File

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

View 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)
}
}

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Some files were not shown because too many files have changed in this diff Show More