feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -1,201 +1,33 @@
|
||||
using System.Security.Claims;
|
||||
using Health.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Health.Application.Admin;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// 管理员 API(需JWT鉴权 + Role=Admin)
|
||||
/// </summary>
|
||||
public static class AdminEndpoints
|
||||
{
|
||||
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
|
||||
private static string GetUserRole(HttpContext http) =>
|
||||
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
|
||||
|
||||
public static void MapAdminEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/admin").RequireAuthorization();
|
||||
|
||||
// 管理员身份校验
|
||||
group.AddEndpointFilter(async (context, next) =>
|
||||
{
|
||||
var role = GetUserRole(context.HttpContext);
|
||||
if (role != "Admin")
|
||||
return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403);
|
||||
return await next(context);
|
||||
});
|
||||
GetRole(context.HttpContext) == "Admin"
|
||||
? await next(context)
|
||||
: Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403));
|
||||
|
||||
// ===== 医生列表 =====
|
||||
group.MapGet("/doctors", async (AppDbContext db) =>
|
||||
{
|
||||
var doctors = await db.Doctors
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.Title, d.Department,
|
||||
d.Phone, d.ProfessionalDirection,
|
||||
d.IsActive, d.AvatarUrl, d.Introduction,
|
||||
d.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
||||
});
|
||||
|
||||
// ===== 新增医生 =====
|
||||
group.MapPost("/doctors", async (
|
||||
AddDoctorRequest request,
|
||||
AppDbContext db,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Phone))
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "手机号不能为空" });
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "姓名不能为空" });
|
||||
|
||||
var doctor = new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = request.Name,
|
||||
Title = request.Title,
|
||||
Department = request.Department,
|
||||
Phone = request.Phone,
|
||||
ProfessionalDirection = request.ProfessionalDirection,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Doctors.Add(doctor);
|
||||
|
||||
// 同步创建 User + DoctorProfile,医生可用手机号登录
|
||||
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||
if (existingUser == null)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = request.Phone,
|
||||
Role = "Doctor",
|
||||
Name = request.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
db.DoctorProfiles.Add(new DoctorProfile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
DoctorId = doctor.Id,
|
||||
Name = request.Name,
|
||||
Title = request.Title,
|
||||
Department = request.Department,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.Name }, message = (string?)null });
|
||||
});
|
||||
|
||||
// ===== 编辑医生 =====
|
||||
group.MapPut("/doctors/{id:guid}", async (
|
||||
Guid id, UpdateDoctorRequest request, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||
|
||||
if (request.Name != null) doctor.Name = request.Name;
|
||||
if (request.Title != null) doctor.Title = request.Title;
|
||||
if (request.Department != null) doctor.Department = request.Department;
|
||||
if (request.Phone != null) doctor.Phone = request.Phone;
|
||||
if (request.ProfessionalDirection != null) doctor.ProfessionalDirection = request.ProfessionalDirection;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { doctor.Id }, message = (string?)null });
|
||||
});
|
||||
|
||||
// ===== 启用/停用医生 =====
|
||||
group.MapPut("/doctors/{id:guid}/disable", async (
|
||||
Guid id, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||
|
||||
doctor.IsActive = !doctor.IsActive;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.IsActive }, message = (string?)null });
|
||||
});
|
||||
|
||||
// ===== 删除医生 =====
|
||||
group.MapDelete("/doctors/{id:guid}", async (
|
||||
Guid id, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||
if (doctor == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||
|
||||
// 清除患者的 DoctorId 关联
|
||||
await db.Users.Where(u => u.DoctorId == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(u => u.DoctorId, (Guid?)null), ct);
|
||||
|
||||
db.Doctors.Remove(doctor);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// ===== 患者列表 =====
|
||||
group.MapGet("/patients", async (
|
||||
AppDbContext db,
|
||||
string? search,
|
||||
int? page,
|
||||
int? pageSize) =>
|
||||
{
|
||||
var p = page ?? 1;
|
||||
var ps = pageSize ?? 20;
|
||||
var query = db.Users
|
||||
.Where(u => u.Role == "User")
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(u => u.Name!.Contains(search) || u.Phone.Contains(search));
|
||||
}
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var patients = await query
|
||||
.OrderByDescending(u => u.CreatedAt)
|
||||
.Skip((p - 1) * ps)
|
||||
.Take(ps)
|
||||
.Select(u => new
|
||||
{
|
||||
u.Id, u.Name, u.Phone, u.Gender,
|
||||
u.BirthDate, u.CreatedAt,
|
||||
DoctorName = u.Doctor != null ? u.Doctor.Name : null,
|
||||
DoctorDepartment = u.Doctor != null ? u.Doctor.Department : null,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new { patients, total, page = p, pageSize = ps },
|
||||
message = (string?)null
|
||||
});
|
||||
});
|
||||
group.MapGet("/doctors", async (IAdminService admin, CancellationToken ct) => ToResult(await admin.ListDoctorsAsync(ct)));
|
||||
group.MapPost("/doctors", async (AddDoctorRequest request, IAdminService admin, CancellationToken ct) =>
|
||||
ToResult(await admin.AddDoctorAsync(new AddDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
|
||||
group.MapPut("/doctors/{id:guid}", async (Guid id, UpdateDoctorRequest request, IAdminService admin, CancellationToken ct) =>
|
||||
ToResult(await admin.UpdateDoctorAsync(id, new UpdateDoctorCommand(request.Phone, request.Name, request.Title, request.Department, request.ProfessionalDirection), ct)));
|
||||
group.MapPut("/doctors/{id:guid}/disable", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.ToggleDoctorAsync(id, ct)));
|
||||
group.MapDelete("/doctors/{id:guid}", async (Guid id, IAdminService admin, CancellationToken ct) => ToResult(await admin.DeleteDoctorAsync(id, ct)));
|
||||
group.MapGet("/patients", async (IAdminService admin, string? search, int? page, int? pageSize, CancellationToken ct) =>
|
||||
ToResult(await admin.ListPatientsAsync(search, page ?? 1, pageSize ?? 20, ct)));
|
||||
}
|
||||
|
||||
private static string GetRole(HttpContext http) =>
|
||||
http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User";
|
||||
private static IResult ToResult(AdminResult result) => Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
|
||||
// ---- DTO ----
|
||||
public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Diets;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
@@ -26,10 +31,12 @@ public static class AiChatEndpoints
|
||||
string token,
|
||||
string agentType,
|
||||
HttpContext http,
|
||||
AppDbContext db,
|
||||
DeepSeekClient llmClient,
|
||||
PromptManager promptManager,
|
||||
VisionClient visionClient,
|
||||
IAiToolExecutionService toolExecution,
|
||||
IAiWriteConfirmationStore confirmations,
|
||||
IAiConversationService conversations,
|
||||
IPatientContextService patientContexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||
@@ -51,38 +58,57 @@ public static class AiChatEndpoints
|
||||
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)
|
||||
// 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
|
||||
Guid? requestedConversationId = null;
|
||||
if (!string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversation = new Conversation
|
||||
if (!Guid.TryParse(conversationId, out var convId))
|
||||
{
|
||||
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);
|
||||
await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct);
|
||||
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
return;
|
||||
}
|
||||
requestedConversationId = convId;
|
||||
}
|
||||
|
||||
// 保存用户消息
|
||||
var userMsg = new ConversationMessage
|
||||
var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
|
||||
if (!opened.Found)
|
||||
{
|
||||
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);
|
||||
await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct);
|
||||
await SseWriteAsync(http, new { action = "status", data = "error" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
return;
|
||||
}
|
||||
var activeConversationId = opened.ConversationId;
|
||||
if (opened.Created)
|
||||
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||
|
||||
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
||||
|
||||
var urgentWarning = DetectUrgentRisk(message);
|
||||
if (!string.IsNullOrEmpty(urgentWarning))
|
||||
{
|
||||
var urgentResponse = $"""
|
||||
{urgentWarning}
|
||||
|
||||
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
|
||||
|
||||
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
||||
""";
|
||||
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||
|
||||
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
||||
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
||||
await SseWriteAsync(http, new { action = "status", data = "done" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载上下文
|
||||
var systemPrompt = promptManager.GetSystemPrompt(parsedType);
|
||||
var patientContext = await BuildPatientContext(db, userId.Value, ct);
|
||||
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
@@ -90,17 +116,13 @@ public static class AiChatEndpoints
|
||||
};
|
||||
|
||||
// 加载历史对话(最近 10 条)
|
||||
var history = await db.ConversationMessages
|
||||
.Where(m => m.ConversationId == conversation.Id)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(12)
|
||||
.ToListAsync(ct);
|
||||
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||
|
||||
foreach (var h in history.Reverse<ConversationMessage>())
|
||||
foreach (var h in history)
|
||||
{
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = h.Role == MessageRole.User ? "user" : "assistant",
|
||||
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
||||
Content = h.Content,
|
||||
});
|
||||
}
|
||||
@@ -155,7 +177,9 @@ public static class AiChatEndpoints
|
||||
object toolResult;
|
||||
try
|
||||
{
|
||||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
|
||||
toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments)
|
||||
? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct)
|
||||
: await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -173,71 +197,62 @@ public static class AiChatEndpoints
|
||||
|
||||
// 保存 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 conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
||||
|
||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
});
|
||||
|
||||
app.MapPost("/api/ai/confirm-write/{commandId:guid}", async (
|
||||
Guid commandId,
|
||||
HttpContext http,
|
||||
IAiToolExecutionService toolExecution,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == null)
|
||||
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||
|
||||
var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct);
|
||||
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}).RequireAuthorization();
|
||||
|
||||
// 获取对话列表
|
||||
app.MapGet("/api/ai/conversations", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, 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);
|
||||
var result = await conversations.ListAsync(userId.Value, ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = conversations, message = (string?)null });
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
// 获取对话历史
|
||||
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, 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);
|
||||
var messages = await conversations.GetMessagesAsync(userId.Value, id, 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) =>
|
||||
app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, 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);
|
||||
}
|
||||
await conversations.DeleteAsync(userId.Value, id, 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,
|
||||
VisionClient visionClient, AppDbContext db,
|
||||
HttpRequest httpRequest,
|
||||
HttpContext http,
|
||||
IDietImageAnalysisCoordinator dietAnalysis,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -245,61 +260,19 @@ public static class AiChatEndpoints
|
||||
|
||||
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)
|
||||
var uploads = files
|
||||
.Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
|
||||
.ToList();
|
||||
try
|
||||
{
|
||||
if (file.Length > 20 * 1024 * 1024)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" });
|
||||
|
||||
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);
|
||||
|
||||
// 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节
|
||||
// base64 ≥ 7MB(官方限制)时才压缩,否则原图上传
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var base64 = Convert.ToBase64String(fileBytes);
|
||||
if (base64.Length > 7 * 1024 * 1024)
|
||||
{
|
||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
||||
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L);
|
||||
fileBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||
base64 = Convert.ToBase64String(fileBytes);
|
||||
}
|
||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||
var result = await dietAnalysis.AnalyzeAsync(uploads, ct);
|
||||
return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var upload in uploads)
|
||||
await upload.Content.DisposeAsync();
|
||||
}
|
||||
|
||||
var prompt = """
|
||||
识别图片中的食物和饮品,返回JSON数组:
|
||||
[{"name":"名称","portion":"份量","calories":热量}]
|
||||
只返回JSON,不要其他内容。
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
// 记录VLM原始返回用于排查
|
||||
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
|
||||
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,60 +321,176 @@ public static class AiChatEndpoints
|
||||
_ => CommonAgentHandler.Tools,
|
||||
};
|
||||
|
||||
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
|
||||
private static bool IsWriteToolCall(string toolName, string arguments)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||
var root = jsonDoc.RootElement;
|
||||
if (toolName == "record_health_data") return true;
|
||||
if (toolName == "manage_archive") return GetToolAction(arguments) != "query";
|
||||
if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm";
|
||||
if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin";
|
||||
return false;
|
||||
}
|
||||
|
||||
return toolName switch
|
||||
private static string GetToolAction(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
||||
"analyze_report" => visionClient != null
|
||||
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
|
||||
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
using var json = JsonDocument.Parse(arguments);
|
||||
return json.RootElement.TryGetProperty("action", out var action)
|
||||
? action.GetString()?.ToLowerInvariant() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<object> PreparePendingWriteAsync(
|
||||
IAiWriteConfirmationStore confirmations,
|
||||
Guid userId,
|
||||
string toolName,
|
||||
string arguments,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct);
|
||||
using var json = JsonDocument.Parse(arguments);
|
||||
var args = json.RootElement;
|
||||
|
||||
var preview = new Dictionary<string, object?>
|
||||
{
|
||||
["success"] = true,
|
||||
["pendingConfirmation"] = true,
|
||||
["confirmationId"] = command.Id,
|
||||
["message"] = "等待用户确认后写入",
|
||||
};
|
||||
|
||||
switch (toolName)
|
||||
{
|
||||
case "record_health_data":
|
||||
AddHealthPreview(preview, args);
|
||||
break;
|
||||
case "manage_medication":
|
||||
preview["type"] = "medication";
|
||||
preview["name"] = GetString(args, "name") ?? "服药确认";
|
||||
preview["dosage"] = GetString(args, "dosage") ?? "";
|
||||
preview["frequency"] = GetString(args, "frequency") ?? "Daily";
|
||||
preview["time"] = GetStringArray(args, "time_of_day");
|
||||
preview["duration_days"] = GetInt(args, "duration_days") ?? 0;
|
||||
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
||||
break;
|
||||
case "manage_exercise":
|
||||
preview["type"] = "exercise";
|
||||
preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动";
|
||||
preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30;
|
||||
preview["day_count"] = GetInt(args, "duration_days") ?? 7;
|
||||
preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd");
|
||||
preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00";
|
||||
break;
|
||||
case "manage_archive":
|
||||
preview["type"] = "archive";
|
||||
preview["value"] = "健康档案更新";
|
||||
preview["unit"] = "";
|
||||
break;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
// ── 患者上下文构建 ──
|
||||
|
||||
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
||||
private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
|
||||
{
|
||||
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)
|
||||
var type = GetString(args, "type") ?? "";
|
||||
switch (type)
|
||||
{
|
||||
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)}");
|
||||
case "blood_pressure":
|
||||
var systolic = GetInt(args, "systolic");
|
||||
var diastolic = GetInt(args, "diastolic");
|
||||
preview["type"] = HealthMetricType.BloodPressure.ToString();
|
||||
preview["value"] = $"{systolic}/{diastolic}";
|
||||
preview["unit"] = "mmHg";
|
||||
preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59;
|
||||
break;
|
||||
case "heart_rate":
|
||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||
break;
|
||||
case "glucose":
|
||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
||||
break;
|
||||
case "spo2":
|
||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||
break;
|
||||
case "weight":
|
||||
AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false);
|
||||
break;
|
||||
}
|
||||
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
|
||||
private static void AddMetricPreview(
|
||||
Dictionary<string, object?> preview,
|
||||
HealthMetricType type,
|
||||
decimal? value,
|
||||
string unit,
|
||||
Func<decimal, bool> isAbnormal)
|
||||
{
|
||||
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}",
|
||||
HealthMetricType.HeartRate => $"{r.Value}次/分",
|
||||
HealthMetricType.Glucose => $"{r.Value}",
|
||||
HealthMetricType.SpO2 => $"{r.Value}%",
|
||||
HealthMetricType.Weight => $"{r.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
preview["type"] = type.ToString();
|
||||
preview["value"] = value?.ToString() ?? "";
|
||||
preview["unit"] = unit;
|
||||
preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
private static int? GetInt(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null;
|
||||
|
||||
private static decimal? GetDecimal(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null;
|
||||
|
||||
private static string GetStringArray(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
: "";
|
||||
|
||||
private static string? DetectUrgentRisk(string message)
|
||||
{
|
||||
var text = message.Trim();
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
|
||||
if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力"))
|
||||
return "你描述的症状可能属于需要紧急评估的危险信号。";
|
||||
|
||||
var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?<sys>\d{2,3})\s*/\s*(?<dia>\d{2,3})");
|
||||
if (bp.Success &&
|
||||
int.TryParse(bp.Groups["sys"].Value, out var systolic) &&
|
||||
int.TryParse(bp.Groups["dia"].Value, out var diastolic) &&
|
||||
(systolic >= 180 || diastolic >= 120))
|
||||
{
|
||||
return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。";
|
||||
}
|
||||
|
||||
var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2");
|
||||
if (spo2.HasValue && spo2.Value <= 90)
|
||||
return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。";
|
||||
|
||||
var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose");
|
||||
if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m))
|
||||
return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。";
|
||||
|
||||
var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr");
|
||||
if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45))
|
||||
return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string text, params string[] keywords) =>
|
||||
keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static decimal? MatchMetricValue(string text, string metricPattern)
|
||||
{
|
||||
var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?<value>\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase);
|
||||
return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null;
|
||||
}
|
||||
|
||||
// ── JSON 类型安全转换 ──
|
||||
private static int _ToInt(object? v) => v switch
|
||||
@@ -435,39 +524,59 @@ public static class AiChatEndpoints
|
||||
catch { resultDict = new Dictionary<string, object>(); }
|
||||
}
|
||||
|
||||
var isPendingConfirmation = resultDict != null
|
||||
&& resultDict.TryGetValue("pendingConfirmation", out var pendingValue)
|
||||
&& _ToBool(pendingValue);
|
||||
if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue))
|
||||
{
|
||||
var confirmationId = confirmationIdValue?.ToString()?.Trim('"');
|
||||
if (!string.IsNullOrWhiteSpace(confirmationId))
|
||||
{
|
||||
if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List<string> ids)
|
||||
{
|
||||
ids = [];
|
||||
metadata["confirmationIds"] = ids;
|
||||
}
|
||||
ids.Add(confirmationId);
|
||||
}
|
||||
}
|
||||
|
||||
switch (toolName)
|
||||
{
|
||||
case "record_health_data":
|
||||
if (!isPendingConfirmation) break;
|
||||
messageType = "data_confirm";
|
||||
if (resultDict != null)
|
||||
{
|
||||
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString();
|
||||
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString();
|
||||
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString();
|
||||
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false;
|
||||
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
|
||||
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn);
|
||||
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success);
|
||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||
}
|
||||
break;
|
||||
case "manage_medication":
|
||||
if (!isPendingConfirmation) break;
|
||||
messageType = "medication_confirm";
|
||||
if (resultDict != null)
|
||||
{
|
||||
if (resultDict.TryGetValue("name", out var name))
|
||||
metadata["name"] = name.ToString();
|
||||
metadata["name"] = name?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||
metadata["dosage"] = dosage.ToString();
|
||||
metadata["dosage"] = dosage?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("time", out var time))
|
||||
metadata["time"] = time.ToString();
|
||||
metadata["time"] = time?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("frequency", out var freq))
|
||||
metadata["frequency"] = freq.ToString();
|
||||
if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2)
|
||||
metadata["duration_days"] = d2;
|
||||
metadata["frequency"] = freq?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("duration_days", out var dd2))
|
||||
metadata["duration_days"] = _ToInt(dd2);
|
||||
if (resultDict.TryGetValue("start_date", out var sd))
|
||||
metadata["startDate"] = sd.ToString();
|
||||
metadata["startDate"] = sd?.ToString() ?? "";
|
||||
}
|
||||
break;
|
||||
case "manage_exercise":
|
||||
if (!isPendingConfirmation) break;
|
||||
messageType = "data_confirm";
|
||||
if (resultDict != null)
|
||||
{
|
||||
@@ -477,40 +586,27 @@ public static class AiChatEndpoints
|
||||
resultDict.TryGetValue("duration_minutes", out var dm);
|
||||
metadata["unit"] = dm != null ? $"每天{dm}分钟" : "";
|
||||
resultDict.TryGetValue("day_count", out var dc);
|
||||
if (dc != null) metadata["durationDays"] = dc.ToString();
|
||||
if (dc != null) metadata["durationDays"] = dc.ToString() ?? "";
|
||||
resultDict.TryGetValue("success", out var ok);
|
||||
metadata["success"] = ok?.ToString() == "True";
|
||||
metadata["success"] = _ToBool(ok);
|
||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||
}
|
||||
break;
|
||||
case "manage_archive":
|
||||
if (!isPendingConfirmation) break;
|
||||
messageType = "data_confirm";
|
||||
metadata["type"] = "archive";
|
||||
metadata["value"] = "健康档案更新";
|
||||
metadata["unit"] = "";
|
||||
metadata["success"] = true;
|
||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||
break;
|
||||
case "analyze_report":
|
||||
messageType = "report_analysis";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 图片处理 ──
|
||||
|
||||
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
|
||||
{
|
||||
using var image = Image.FromFile(inputPath);
|
||||
var width = image.Width;
|
||||
var height = image.Height;
|
||||
if (width > maxWidth)
|
||||
{
|
||||
height = (int)((double)height / width * maxWidth);
|
||||
width = maxWidth;
|
||||
}
|
||||
using var bitmap = new Bitmap(width, height);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
||||
graphics.DrawImage(image, 0, 0, width, height);
|
||||
|
||||
var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
|
||||
var parameters = new EncoderParameters(1);
|
||||
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
|
||||
bitmap.Save(outputPath, jpegCodec, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||
|
||||
@@ -1,281 +1,30 @@
|
||||
using Health.Infrastructure.Services;
|
||||
using Health.Application.Auth;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// 认证相关 API 端点
|
||||
/// </summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
private const string AdminPhone = "12345678910";
|
||||
private const string AdminSmsCode = "000000";
|
||||
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
// 发送短信验证码
|
||||
app.MapPost("/api/auth/send-sms", async (
|
||||
SendSmsRequest request,
|
||||
AppDbContext db,
|
||||
SmsService sms,
|
||||
CancellationToken ct) =>
|
||||
app.MapPost("/api/auth/send-sms", async (SendSmsRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.SendCodeAsync(request.Phone, app.Environment.IsDevelopment(), ct)));
|
||||
app.MapPost("/api/auth/register", async (RegisterRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RegisterAsync(new RegisterCommand(request.Phone, request.SmsCode, request.Name, request.DoctorId), ct)));
|
||||
app.MapPost("/api/auth/login", async (LoginRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.LoginAsync(request.Phone, request.SmsCode, ct)));
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
ToResult(await auth.RefreshAsync(request.RefreshToken, ct)));
|
||||
app.MapPost("/api/auth/logout", async (RefreshRequest request, IAuthService auth, CancellationToken ct) =>
|
||||
{
|
||||
var code = request.Phone == AdminPhone ? AdminSmsCode : 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);
|
||||
|
||||
if (request.Phone != AdminPhone)
|
||||
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/register", async (
|
||||
RegisterRequest request,
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// 验证码
|
||||
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 existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||
if (existing != null)
|
||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" });
|
||||
|
||||
// 校验姓名
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Results.Ok(new { code = 40003, data = (object?)null, message = "请输入姓名" });
|
||||
|
||||
// 校验医生
|
||||
var doctor = await db.Doctors.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct);
|
||||
if (doctor == null)
|
||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "所选医生不存在或已停诊" });
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = request.Phone,
|
||||
Role = "User",
|
||||
Name = request.Name,
|
||||
DoctorId = request.DoctorId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
|
||||
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
|
||||
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, "User");
|
||||
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(ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user = new { user.Id, user.Phone, user.Role, isNew = true }
|
||||
},
|
||||
message = (string?)null
|
||||
});
|
||||
});
|
||||
|
||||
// ── 登录(已有账号 / 管理员)──
|
||||
app.MapPost("/api/auth/login", async (
|
||||
LoginRequest request,
|
||||
AppDbContext db,
|
||||
JwtProvider jwt,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
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;
|
||||
|
||||
// 管理员登录
|
||||
if (request.Phone == AdminPhone)
|
||||
{
|
||||
var adminAccessToken = jwt.GenerateAccessToken(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
||||
var adminRefreshToken = jwt.GenerateRefreshToken();
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
Token = adminRefreshToken,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
accessToken = adminAccessToken,
|
||||
refreshToken = adminRefreshToken,
|
||||
user = new { id = "00000000-0000-0000-0000-000000000001", phone = AdminPhone, role = "Admin", name = "管理员", isNew = false }
|
||||
},
|
||||
message = (string?)null
|
||||
});
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||
if (user == null)
|
||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先登录" });
|
||||
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
||||
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(ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user = new {
|
||||
user.Id, user.Phone, user.Role, user.Name,
|
||||
user.Gender, user.AvatarUrl,
|
||||
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||
isNew = false
|
||||
}
|
||||
},
|
||||
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 = "登录已过期,请重新登录" });
|
||||
|
||||
oldToken.IsRevoked = true;
|
||||
|
||||
var user = await db.Users.FindAsync([oldToken.UserId], ct);
|
||||
|
||||
// 管理员刷新:无 User 记录,直接用固定信息
|
||||
if (user == null && oldToken.UserId == Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
var adminAccessToken = jwt.GenerateAccessToken(
|
||||
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
||||
var adminNewRefresh = jwt.GenerateRefreshToken();
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
Token = adminNewRefresh,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new { accessToken = adminAccessToken, refreshToken = adminNewRefresh, user = new { role = "Admin" } },
|
||||
message = (string?)null
|
||||
});
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
||||
|
||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
||||
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, user = new { user.Role } },
|
||||
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);
|
||||
|
||||
await auth.LogoutAsync(request.RefreshToken, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult ToResult(AuthResult result) =>
|
||||
Results.Ok(new { code = result.Code, data = result.Data, message = result.Message });
|
||||
}
|
||||
|
||||
// ---- 请求 DTO ----
|
||||
public sealed record SendSmsRequest(string Phone);
|
||||
public sealed record RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||
public sealed record LoginRequest(string Phone, string SmsCode);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.Calendars;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class CalendarEndpoints
|
||||
@@ -6,91 +8,22 @@ public static class CalendarEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/calendar").RequireAuthorization();
|
||||
|
||||
group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/", async (int year, int month, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var start = new DateOnly(year, month, 1);
|
||||
var end = start.AddMonths(1);
|
||||
var startDt = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endDt = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
|
||||
.ToListAsync(ct);
|
||||
var plans = await db.ExercisePlans
|
||||
.Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct);
|
||||
var followups = await db.FollowUps
|
||||
.Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = new List<object>();
|
||||
for (var d = start; d < end; d = d.AddDays(1))
|
||||
{
|
||||
var detailList = new List<object>();
|
||||
|
||||
// 用药:当前日期在用药有效期内
|
||||
var todayMedications = medications
|
||||
.Where(m => m.StartDate <= d && (m.EndDate == null || m.EndDate >= d));
|
||||
foreach (var m in todayMedications)
|
||||
{
|
||||
detailList.Add(new { type = "medication", name = m.Name, dosage = m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() });
|
||||
}
|
||||
|
||||
// 运动:按计划有效周范围 + 星期几匹配
|
||||
foreach (var p in plans)
|
||||
{
|
||||
var weekEnd = p.WeekStartDate.AddDays(6);
|
||||
if (d >= p.WeekStartDate && d <= weekEnd)
|
||||
{
|
||||
var dayItems = p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay);
|
||||
foreach (var i in dayItems)
|
||||
detailList.Add(new { type = "exercise", name = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
|
||||
}
|
||||
}
|
||||
|
||||
// 随访
|
||||
var todayFollowups = followups.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == d);
|
||||
foreach (var f in todayFollowups)
|
||||
detailList.Add(new { type = "followup", title = f.Title, doctorName = f.DoctorName, department = f.Department, status = f.Status.ToString() });
|
||||
|
||||
if (detailList.Count > 0)
|
||||
result.Add(new { date = d.ToString("yyyy-MM-dd"), events = detailList.Select(x => ((dynamic)x).type as string).Distinct().ToList(), details = detailList });
|
||||
}
|
||||
if (year is < 2000 or > 2100 || month is < 1 or > 12)
|
||||
return Results.Ok(new { code = 400, data = (object?)null, message = "年月格式错误" });
|
||||
|
||||
var result = await calendar.GetMonthAsync(GetUserId(http), year, month, ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
// 获取某一天的详细安排
|
||||
group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/day", async (string date, HttpContext http, ICalendarService calendar, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (!DateOnly.TryParse(date, out var d))
|
||||
if (!DateOnly.TryParse(date, out var parsedDate))
|
||||
return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" });
|
||||
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d))
|
||||
.Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var plans = await db.ExercisePlans
|
||||
.Where(p => p.UserId == userId)
|
||||
.Include(p => p.Items).ToListAsync(ct);
|
||||
var exercises = new List<object>();
|
||||
foreach (var p in plans)
|
||||
{
|
||||
if (d >= p.WeekStartDate && d <= p.WeekStartDate.AddDays(6))
|
||||
{
|
||||
foreach (var i in p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay))
|
||||
exercises.Add(new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted });
|
||||
}
|
||||
}
|
||||
|
||||
var followUps = await db.FollowUps
|
||||
.Where(f => f.UserId == userId && DateOnly.FromDateTime(f.ScheduledAt) == d)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { medications, exercises, followUps }, message = (string?)null });
|
||||
var result = await calendar.GetDayAsync(GetUserId(http), parsedDate, ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,14 @@ public static class ConsultationEndpoints
|
||||
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, 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 };
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId, ct);
|
||||
if (consultation == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||
|
||||
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = consultation.Id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||
db.ConsultationMessages.Add(msg);
|
||||
|
||||
// 用户发消息后,状态从 AiTalking → WaitingDoctor
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
|
||||
{
|
||||
consultation.Status = ConsultationStatus.WaitingDoctor;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.Diets;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class DietEndpoints
|
||||
@@ -6,82 +8,91 @@ public static class DietEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
||||
|
||||
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/", async (string? date, string? mealType, HttpContext http, IDietService diets, 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).Select(r => new
|
||||
{
|
||||
r.Id, MealType = r.MealType.ToString(), r.TotalCalories, r.HealthScore, r.RecordedAt,
|
||||
foodItems = r.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories })
|
||||
}).ToListAsync(ct);
|
||||
var records = await diets.ListAsync(userId, date, mealType, ct);
|
||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var root = json.RootElement;
|
||||
var record = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch,
|
||||
TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
|
||||
HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
|
||||
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
if (root.TryGetProperty("foodItems", out var items))
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var fi in items.EnumerateArray())
|
||||
{
|
||||
record.FoodItems.Add(new DietFoodItem
|
||||
{
|
||||
Id = Guid.NewGuid(), Name = fi.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
||||
Portion = fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
|
||||
Calories = fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
|
||||
SortOrder = fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
db.DietRecords.Add(record);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
||||
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||
var recordId = await diets.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
|
||||
return Results.Ok(new { code = 0, data = new { id = recordId }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, 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); }
|
||||
await diets.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||
if (record == null) return Results.Ok(new { code = 40004, message = "记录不存在" });
|
||||
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
|
||||
var updated = await diets.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "记录不存在" });
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (json.RootElement.TryGetProperty("totalCalories", out var cal)) record.TotalCalories = (int?)cal.GetInt32();
|
||||
if (json.RootElement.TryGetProperty("healthScore", out var hs)) record.HealthScore = (int?)hs.GetInt32();
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static DietRecordCreateRequest ReadCreateRequest(JsonElement root)
|
||||
{
|
||||
var mealType = Enum.TryParse<MealType>(
|
||||
root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch",
|
||||
out var meal)
|
||||
? meal
|
||||
: MealType.Lunch;
|
||||
var recordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d)
|
||||
? d
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
|
||||
return new DietRecordCreateRequest(
|
||||
mealType,
|
||||
root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
|
||||
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
|
||||
recordedAt,
|
||||
ReadFoodItems(root));
|
||||
}
|
||||
|
||||
private static DietRecordPatchRequest ReadPatchRequest(JsonElement root) => new(
|
||||
root.TryGetProperty("totalCalories", out var cal) ? cal.GetInt32() : null,
|
||||
root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null);
|
||||
|
||||
private static List<DietFoodItemInput> ReadFoodItems(JsonElement root)
|
||||
{
|
||||
var result = new List<DietFoodItemInput>();
|
||||
if (!root.TryGetProperty("foodItems", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
|
||||
var i = 0;
|
||||
foreach (var fi in items.EnumerateArray())
|
||||
{
|
||||
result.Add(new DietFoodItemInput(
|
||||
fi.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
|
||||
fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
|
||||
fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
|
||||
fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i));
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||
{
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,31 +49,34 @@ public static class DoctorEndpoints
|
||||
var profile = await GetDoctorProfile(http, db);
|
||||
if (profile == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" });
|
||||
if (profile.DoctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User");
|
||||
var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed);
|
||||
var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor);
|
||||
var doctorId = profile.DoctorId.Value;
|
||||
var totalPatients = await db.Users.CountAsync(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||
var activeConsultations = await db.Consultations.CountAsync(c => c.User.DoctorId == doctorId && c.Status != ConsultationStatus.Closed);
|
||||
var pendingReports = await db.Reports.CountAsync(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor);
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
||||
|
||||
// 待办列表
|
||||
var pendingConsultations = await db.Consultations
|
||||
.Where(c => c.Status == ConsultationStatus.WaitingDoctor)
|
||||
.Where(c => c.User.DoctorId == doctorId && c.Status == ConsultationStatus.WaitingDoctor)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(5)
|
||||
.Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var pendingReportList = await db.Reports
|
||||
.Where(r => r.Status == ReportStatus.PendingDoctor)
|
||||
.Where(r => r.User.DoctorId == doctorId && r.Status == ReportStatus.PendingDoctor)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(5)
|
||||
.Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var todayFollowUpList = await db.FollowUps
|
||||
.Where(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming)
|
||||
.Where(f => f.User.DoctorId == doctorId && f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming)
|
||||
.OrderBy(f => f.ScheduledAt)
|
||||
.Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt })
|
||||
.ToListAsync();
|
||||
@@ -121,11 +124,15 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 患者详情 =====
|
||||
group.MapGet("/patients/{id:guid}", async (Guid id, AppDbContext db) =>
|
||||
group.MapGet("/patients/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var user = await db.Users
|
||||
.Include(u => u.HealthArchive)
|
||||
.FirstOrDefaultAsync(u => u.Id == id);
|
||||
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
|
||||
if (user == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||
|
||||
@@ -153,7 +160,7 @@ public static class DoctorEndpoints
|
||||
.ToListAsync();
|
||||
|
||||
var exercisePlans = await db.ExercisePlans
|
||||
.Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items)
|
||||
.Where(p => p.UserId == id).OrderByDescending(p => p.StartDate).Take(1).Include(p => p.Items)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var reports = await db.Reports
|
||||
@@ -177,7 +184,7 @@ public static class DoctorEndpoints
|
||||
trendRecords,
|
||||
medications,
|
||||
dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }),
|
||||
exercisePlan = exercisePlans == null ? null : new { exercisePlans.WeekStartDate, items = exercisePlans.Items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) },
|
||||
exercisePlan = exercisePlans == null ? null : new { exercisePlans.StartDate, exercisePlans.EndDate, exercisePlans.ReminderTime, items = exercisePlans.Items.Select(i => new { i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) },
|
||||
reports, followUps
|
||||
},
|
||||
message = (string?)null
|
||||
@@ -189,8 +196,10 @@ public static class DoctorEndpoints
|
||||
{
|
||||
var profile = await GetDoctorProfile(http, db);
|
||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var consultations = await db.Consultations
|
||||
.Where(c => c.User.DoctorId == profile.DoctorId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() })
|
||||
.ToListAsync();
|
||||
@@ -199,15 +208,22 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 问诊消息 =====
|
||||
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) =>
|
||||
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var consultation = await db.Consultations
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == doctorId);
|
||||
if (consultation == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||
|
||||
var messages = await db.ConsultationMessages
|
||||
.Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt)
|
||||
.Where(m => m.ConsultationId == consultation.Id).OrderBy(m => m.CreatedAt)
|
||||
.Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null });
|
||||
});
|
||||
|
||||
@@ -216,8 +232,9 @@ public static class DoctorEndpoints
|
||||
{
|
||||
var profile = await GetDoctorProfile(http, db);
|
||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id && c.User.DoctorId == profile.DoctorId, ct);
|
||||
if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" });
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
@@ -249,9 +266,13 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 报告列表 =====
|
||||
group.MapGet("/reports", async (string? status, AppDbContext db) =>
|
||||
group.MapGet("/reports", async (string? status, HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var query = db.Reports.AsQueryable();
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var query = db.Reports.Where(r => r.User.DoctorId == doctorId);
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s))
|
||||
query = query.Where(r => r.Status == s);
|
||||
|
||||
@@ -262,9 +283,13 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 报告详情 =====
|
||||
group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) =>
|
||||
group.MapGet("/reports/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var report = await db.Reports.Where(r => r.Id == id)
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||
@@ -276,8 +301,9 @@ public static class DoctorEndpoints
|
||||
{
|
||||
var profile = await GetDoctorProfile(http, db);
|
||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.User.DoctorId == profile.DoctorId, ct);
|
||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
@@ -299,9 +325,13 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 随访列表 =====
|
||||
group.MapGet("/follow-ups", async (string? status, string? patientId, AppDbContext db) =>
|
||||
group.MapGet("/follow-ups", async (string? status, string? patientId, HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var query = db.FollowUps.AsQueryable();
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var query = db.FollowUps.Where(f => f.User.DoctorId == doctorId);
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FollowUpStatus>(status, out var s))
|
||||
query = query.Where(f => f.Status == s);
|
||||
if (Guid.TryParse(patientId, out var pid))
|
||||
@@ -318,15 +348,19 @@ public static class DoctorEndpoints
|
||||
{
|
||||
var profile = await GetDoctorProfile(http, db);
|
||||
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||
if (profile.DoctorId == null) return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
var patientId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!);
|
||||
var patientExists = await db.Users.AnyAsync(u => u.Id == patientId && u.DoctorId == profile.DoctorId, ct);
|
||||
if (!patientExists) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||
|
||||
var followUp = new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!),
|
||||
UserId = patientId,
|
||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||
DoctorName = profile.Name,
|
||||
Department = profile.Department,
|
||||
@@ -343,7 +377,11 @@ public static class DoctorEndpoints
|
||||
// ===== 更新随访 =====
|
||||
group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
|
||||
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
@@ -358,9 +396,13 @@ public static class DoctorEndpoints
|
||||
});
|
||||
|
||||
// ===== 删除随访 =====
|
||||
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id && f.User.DoctorId == doctorId, ct);
|
||||
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||
db.FollowUps.Remove(followUp);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -371,9 +413,9 @@ public static class DoctorEndpoints
|
||||
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var doctorId = await GetDoctorEntityId(http, db);
|
||||
var query = db.Users.Where(u => u.Role == "User");
|
||||
if (doctorId != null)
|
||||
query = query.Where(u => u.DoctorId == doctorId);
|
||||
if (doctorId == null)
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
var query = db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
|
||||
var patients = await query.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync();
|
||||
return Results.Ok(new { code = 0, data = patients, message = (string?)null });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.Exercises;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class ExerciseEndpoints
|
||||
@@ -6,126 +8,78 @@ public static class ExerciseEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
||||
|
||||
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
||||
group.MapGet("/current", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
|
||||
// 查找覆盖今天的所有计划(不仅限于周一开始的)
|
||||
var plans = await db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId && p.WeekStartDate <= today)
|
||||
.OrderByDescending(p => p.WeekStartDate)
|
||||
.ToListAsync();
|
||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek));
|
||||
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
||||
items = plan.Items.Select(i => new
|
||||
{
|
||||
i.Id, i.DayOfWeek,
|
||||
i.ExerciseType, i.DurationMinutes,
|
||||
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
||||
})
|
||||
},
|
||||
message = (string?)null
|
||||
});
|
||||
var plan = await exercises.GetCurrentAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = plan, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
var body = await ReadBodyAsync(request, ct);
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var root = json.RootElement;
|
||||
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
|
||||
|
||||
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString() : "运动";
|
||||
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
|
||||
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")));
|
||||
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate };
|
||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DayOfWeek = (int)d.DayOfWeek,
|
||||
ExerciseType = exerciseType,
|
||||
DurationMinutes = duration,
|
||||
IsRestDay = false,
|
||||
});
|
||||
}
|
||||
db.ExercisePlans.Add(plan);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
||||
var startDate = DateOnly.Parse(root.TryGetProperty("startDate", out var sd)
|
||||
? sd.GetString()!
|
||||
: DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"));
|
||||
var endDate = DateOnly.Parse(root.TryGetProperty("endDate", out var ed)
|
||||
? ed.GetString()!
|
||||
: DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"));
|
||||
var reminderTime = root.TryGetProperty("reminderTime", out var rt) && TimeOnly.TryParse(rt.GetString(), out var parsedTime)
|
||||
? parsedTime
|
||||
: new TimeOnly(19, 0);
|
||||
|
||||
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(startDate, endDate, exerciseType, duration, reminderTime), ct);
|
||||
return Results.Ok(new { code = 0, data = new { id = planId }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/", async (HttpContext http, AppDbContext db) =>
|
||||
group.MapGet("/", async (HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var plans = await db.ExercisePlans.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.WeekStartDate).Take(20)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id, p.WeekStartDate, p.CreatedAt,
|
||||
totalDays = p.Items.Count,
|
||||
completedDays = p.Items.Count(i => i.IsCompleted),
|
||||
items = p.Items.OrderBy(i => i.DayOfWeek).Select(i => new
|
||||
{
|
||||
i.Id, i.DayOfWeek,
|
||||
i.ExerciseType, i.DurationMinutes,
|
||||
i.IsCompleted, i.IsRestDay
|
||||
})
|
||||
}).ToListAsync();
|
||||
var plans = await exercises.ListAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = plans, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db) =>
|
||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var plan = await db.ExercisePlans.Include(p => p.Items)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
|
||||
var plan = await exercises.GetByIdAsync(userId, id, ct);
|
||||
if (plan == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new
|
||||
{
|
||||
plan.Id, plan.WeekStartDate, plan.CreatedAt,
|
||||
items = plan.Items.OrderBy(i => i.DayOfWeek).Select(i => new
|
||||
{
|
||||
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
|
||||
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
||||
})
|
||||
},
|
||||
message = (string?)null
|
||||
});
|
||||
|
||||
return Results.Ok(new { code = 0, data = plan, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId, ct);
|
||||
if (plan != null) { db.ExercisePlans.Remove(plan); await db.SaveChangesAsync(ct); }
|
||||
await exercises.DeleteAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, IExerciseService exercises, CancellationToken ct) =>
|
||||
{
|
||||
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
||||
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||
item.IsCompleted = !item.IsCompleted;
|
||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { completed = item.IsCompleted }, message = (string?)null });
|
||||
var userId = GetUserId(http);
|
||||
var completed = await exercises.ToggleCheckInAsync(userId, itemId, ct);
|
||||
if (completed == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { completed }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||
{
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,55 @@ namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class FileEndpoints
|
||||
{
|
||||
private const long MaxFileSize = 20 * 1024 * 1024;
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
|
||||
};
|
||||
|
||||
public static void MapFileEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||
|
||||
group.MapPost("/upload", async (HttpRequest request) =>
|
||||
group.MapPost("/upload", async (HttpRequest request, CancellationToken ct) =>
|
||||
{
|
||||
var form = await request.ReadFormAsync();
|
||||
if (!request.HasFormContentType)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "需要 multipart/form-data" });
|
||||
|
||||
var form = await request.ReadFormAsync(ct);
|
||||
var files = form.Files;
|
||||
if (files.Count == 0)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "未上传文件" });
|
||||
|
||||
var results = new List<object>();
|
||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
Directory.CreateDirectory(uploadsDir);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
continue;
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小不能超过 20MB" });
|
||||
|
||||
var fileId = Guid.NewGuid().ToString();
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的文件类型" });
|
||||
|
||||
var storedName = $"{fileId}{ext}";
|
||||
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 });
|
||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||
await file.CopyToAsync(stream, ct);
|
||||
results.Add(new
|
||||
{
|
||||
id = fileId,
|
||||
name = file.FileName,
|
||||
size = file.Length,
|
||||
url = $"/uploads/{storedName}",
|
||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.HealthRecords;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,127 +11,82 @@ public static class HealthEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/health-records").RequireAuthorization();
|
||||
|
||||
// 查询健康记录
|
||||
group.MapGet("/", async (
|
||||
string? type, int? days,
|
||||
HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
string? type,
|
||||
int? days,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
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);
|
||||
|
||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||
});
|
||||
|
||||
// 新增健康记录
|
||||
group.MapPost("/", async (CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/", async (
|
||||
CreateHealthRecordRequest req,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
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 });
|
||||
var id = await healthRecords.CreateAsync(userId, req.ToServiceRequest(), ct);
|
||||
return Results.Ok(new { code = 0, data = new { id }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 修改健康记录
|
||||
group.MapPut("/{id:guid}", async (Guid id, CreateHealthRecordRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPut("/{id:guid}", async (
|
||||
Guid id,
|
||||
CreateHealthRecordRequest req,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
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 });
|
||||
var ok = await healthRecords.UpdateAsync(userId, id, req.ToServiceRequest(), ct);
|
||||
return ok
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
||||
});
|
||||
|
||||
// 删除健康记录
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/{id:guid}", async (
|
||||
Guid id,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
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 = "记录不存在" });
|
||||
db.HealthRecords.Remove(record);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var ok = await healthRecords.DeleteAsync(userId, id, ct);
|
||||
return ok
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "记录不存在" });
|
||||
});
|
||||
|
||||
// 获取各指标最新值
|
||||
group.MapGet("/latest", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/latest", async (
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
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 });
|
||||
var latest = await healthRecords.GetLatestAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = latest, message = (string?)null });
|
||||
});
|
||||
|
||||
// 趋势数据
|
||||
group.MapGet("/trend", async (string type, int period, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/trend", async (
|
||||
string type,
|
||||
int period,
|
||||
HttpContext http,
|
||||
IHealthRecordService healthRecords,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||
if (!Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var metricType))
|
||||
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);
|
||||
|
||||
var records = await healthRecords.GetTrendAsync(userId, metricType, period, 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,
|
||||
HealthMetricType.Weight => false, // 体重无标准异常范围
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
@@ -143,4 +100,13 @@ public sealed class CreateHealthRecordRequest
|
||||
public string? Unit { get; init; }
|
||||
public HealthRecordSource Source { get; init; }
|
||||
public DateTime? RecordedAt { get; init; }
|
||||
|
||||
public HealthRecordUpsertRequest ToServiceRequest() => new(
|
||||
Type,
|
||||
Systolic,
|
||||
Diastolic,
|
||||
Value,
|
||||
Unit,
|
||||
Source,
|
||||
RecordedAt);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class MedicationEndpoints
|
||||
@@ -6,255 +8,139 @@ public static class MedicationEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
||||
|
||||
group.MapGet("/", async (string? filter, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/", async (string? filter, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var query = db.Medications.Where(m => m.UserId == userId);
|
||||
if (filter == "active") query = query.Where(m => m.IsActive);
|
||||
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
|
||||
// 北京时间今天对应的 UTC 区间
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new
|
||||
{
|
||||
m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(),
|
||||
m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes,
|
||||
Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt,
|
||||
todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken)
|
||||
}).ToListAsync(ct);
|
||||
var meds = await medications.ListAsync(userId, filter, ct);
|
||||
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/", async (HttpRequest request, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var root = json.RootElement;
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
Name = root.GetProperty("name").GetString()!,
|
||||
Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||
Frequency = Enum.TryParse<MedicationFrequency>(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily,
|
||||
Source = Enum.TryParse<MedicationSource>(root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", out var src) ? src : MedicationSource.Manual,
|
||||
IsActive = true,
|
||||
};
|
||||
if (root.TryGetProperty("timeOfDay", out var tod))
|
||||
med.TimeOfDay = tod.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
||||
if (root.TryGetProperty("startDate", out var sd))
|
||||
med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null;
|
||||
if (root.TryGetProperty("endDate", out var ed))
|
||||
med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null;
|
||||
if (root.TryGetProperty("notes", out var nt)) med.Notes = nt.GetString();
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
||||
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||
var medId = await medications.CreateAsync(userId, ReadCreateRequest(json.RootElement), ct);
|
||||
return Results.Ok(new { code = 0, data = new { id = medId }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, IMedicationService medications, 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 = "不存在" });
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
using var json = JsonDocument.Parse(body);
|
||||
var root = json.RootElement;
|
||||
if (root.TryGetProperty("name", out var n)) med.Name = n.GetString()!;
|
||||
if (root.TryGetProperty("dosage", out var dg2)) med.Dosage = dg2.GetString();
|
||||
if (root.TryGetProperty("frequency", out var f2) && Enum.TryParse<MedicationFrequency>(f2.GetString(), out var freq2)) med.Frequency = freq2;
|
||||
if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
||||
if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3;
|
||||
if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4;
|
||||
if (root.TryGetProperty("notes", out var nt2)) med.Notes = nt2.GetString();
|
||||
med.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
using var json = JsonDocument.Parse(await ReadBodyAsync(request, ct));
|
||||
var updated = await medications.UpdateAsync(userId, id, ReadPatchRequest(json.RootElement), ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||
|
||||
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) =>
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, 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); }
|
||||
await medications.DeleteAsync(userId, id, 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) =>
|
||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct);
|
||||
if (existing != null) {
|
||||
db.MedicationLogs.Remove(existing);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { taken = false }, message = (string?)null });
|
||||
}
|
||||
var log = new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.MedicationLogs.Add(log);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { taken = true }, message = (string?)null });
|
||||
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
|
||||
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var today = DateOnly.FromDateTime(beijingNow);
|
||||
var todayUtc = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
||||
|
||||
// 只查活跃且在有效期内的药
|
||||
var meds = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null
|
||||
&& m.StartDate <= today && (m.EndDate == null || m.EndDate >= today))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 查询今天所有服药记录
|
||||
var logs = await db.MedicationLogs
|
||||
.Where(l => l.UserId == userId && l.ConfirmedAt >= todayUtc)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 按次生成提醒
|
||||
var reminders = new List<object>();
|
||||
foreach (var m in meds)
|
||||
{
|
||||
// 处理隔天/每周的逻辑
|
||||
var dosesToday = GetDosesForToday(m, today);
|
||||
if (!dosesToday) continue;
|
||||
|
||||
foreach (var t in m.TimeOfDay!)
|
||||
{
|
||||
var scheduledTime = t;
|
||||
var doseTimeBeijing = today.ToDateTime(scheduledTime);
|
||||
|
||||
// 查该药该时间是否已打卡
|
||||
var alreadyLogged = logs.Any(l =>
|
||||
l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
|
||||
|
||||
string status;
|
||||
if (alreadyLogged)
|
||||
{
|
||||
var log = logs.First(l => l.MedicationId == m.Id && l.ScheduledTime == scheduledTime);
|
||||
status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped";
|
||||
}
|
||||
else if (scheduledTime <= now.AddMinutes(-30))
|
||||
{
|
||||
status = "overdue"; // 过了30分钟还没吃
|
||||
}
|
||||
else if (scheduledTime <= now.AddHours(3))
|
||||
{
|
||||
status = "upcoming"; // 未来3小时内
|
||||
}
|
||||
else
|
||||
{
|
||||
continue; // 太远,不显示
|
||||
}
|
||||
|
||||
reminders.Add(new
|
||||
{
|
||||
m.Id, m.Name, m.Dosage,
|
||||
scheduledTime = scheduledTime.ToString("HH:mm"),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// overdue 在前,upcoming 在后,taken 在最后
|
||||
reminders = reminders.OrderBy(r =>
|
||||
((dynamic)r).status == "overdue" ? 0 :
|
||||
((dynamic)r).status == "upcoming" ? 1 : 2
|
||||
).ToList();
|
||||
|
||||
var reminders = await medications.GetRemindersAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = reminders, message = (string?)null });
|
||||
});
|
||||
|
||||
// 按次打卡
|
||||
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, IMedicationService medications, 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 = 404, message = "药品不存在" });
|
||||
|
||||
using var reader = new System.IO.StreamReader(http.Request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
using var json = JsonDocument.Parse(await ReadBodyAsync(http.Request, ct));
|
||||
var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!;
|
||||
var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken";
|
||||
var scheduledTime = TimeOnly.Parse(timeStr);
|
||||
var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped;
|
||||
|
||||
// 防止重复打卡
|
||||
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
|
||||
var existing = await db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == id && l.UserId == userId
|
||||
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc, ct);
|
||||
if (existing) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
|
||||
var result = await medications.ConfirmDoseAsync(userId, id, scheduledTime, status, ct);
|
||||
if (result == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||
if (result == false) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null });
|
||||
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||
Status = status, ScheduledTime = scheduledTime, ConfirmedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 取消打卡(删掉对应 log)
|
||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var scheduledTime = TimeOnly.Parse(time);
|
||||
var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc);
|
||||
var log = await db.MedicationLogs
|
||||
.Where(l => l.MedicationId == id && l.UserId == userId
|
||||
&& l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (log != null) { db.MedicationLogs.Remove(log); await db.SaveChangesAsync(ct); }
|
||||
await medications.CancelDoseAsync(userId, id, TimeOnly.Parse(time), ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断今天是否该吃药(处理隔天/每周频率)
|
||||
/// </summary>
|
||||
private static bool GetDosesForToday(Medication m, DateOnly today)
|
||||
private static MedicationUpsertRequest ReadCreateRequest(JsonElement root)
|
||||
{
|
||||
if (m.Frequency == MedicationFrequency.Daily) return true;
|
||||
if (m.Frequency == MedicationFrequency.TwiceDaily) return true;
|
||||
if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true;
|
||||
if (m.Frequency == MedicationFrequency.AsNeeded) return true;
|
||||
var frequency = Enum.TryParse<MedicationFrequency>(
|
||||
root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily",
|
||||
out var freq)
|
||||
? freq
|
||||
: MedicationFrequency.Daily;
|
||||
var source = Enum.TryParse<MedicationSource>(
|
||||
root.TryGetProperty("source", out var s) ? s.GetString() : "Manual",
|
||||
out var src)
|
||||
? src
|
||||
: MedicationSource.Manual;
|
||||
|
||||
var startDate = m.StartDate ?? today;
|
||||
if (m.Frequency == MedicationFrequency.EveryOtherDay)
|
||||
{
|
||||
var daysSinceStart = today.DayNumber - startDate.DayNumber;
|
||||
return daysSinceStart % 2 == 0;
|
||||
}
|
||||
if (m.Frequency == MedicationFrequency.Weekly)
|
||||
{
|
||||
return today.DayOfWeek == startDate.DayOfWeek;
|
||||
}
|
||||
return true;
|
||||
return new MedicationUpsertRequest(
|
||||
root.GetProperty("name").GetString()!,
|
||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||
frequency,
|
||||
ReadTimes(root, "timeOfDay"),
|
||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
source,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||
}
|
||||
|
||||
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
|
||||
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
|
||||
private static MedicationPatchRequest ReadPatchRequest(JsonElement root)
|
||||
{
|
||||
MedicationFrequency? frequency = null;
|
||||
if (root.TryGetProperty("frequency", out var f) && Enum.TryParse<MedicationFrequency>(f.GetString(), out var freq))
|
||||
frequency = freq;
|
||||
|
||||
return new MedicationPatchRequest(
|
||||
root.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||
root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||
frequency,
|
||||
root.TryGetProperty("timeOfDay", out _) ? ReadTimes(root, "timeOfDay") : null,
|
||||
root.TryGetProperty("startDate", out var sd) && DateOnly.TryParse(sd.GetString(), out var startDate) ? startDate : null,
|
||||
root.TryGetProperty("endDate", out var ed) && DateOnly.TryParse(ed.GetString(), out var endDate) ? endDate : null,
|
||||
root.TryGetProperty("notes", out var nt) ? nt.GetString() : null);
|
||||
}
|
||||
|
||||
private static List<TimeOnly> ReadTimes(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var tod) || tod.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return tod.EnumerateArray()
|
||||
.Select(t => TimeOnly.TryParse(t.GetString(), out var parsed) ? parsed : (TimeOnly?)null)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpRequest request, CancellationToken ct)
|
||||
{
|
||||
request.EnableBuffering();
|
||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
request.Body.Position = 0;
|
||||
return string.IsNullOrWhiteSpace(body) ? "{}" : body;
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Claims;
|
||||
using Health.Application.Notifications;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class NotificationEndpoints
|
||||
{
|
||||
public static void MapNotificationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/notifications").RequireAuthorization();
|
||||
|
||||
group.MapGet("/pending", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var result = await notifications.GetPendingAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Application.Reports;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
@@ -8,294 +8,62 @@ public static class ReportEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
||||
|
||||
// 列表
|
||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/", async (HttpContext http, IReportService reports, 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 });
|
||||
var data = await reports.GetReportsAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data, message = (string?)null });
|
||||
});
|
||||
|
||||
// 删除
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||
if (report == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||
// 删除本地文件
|
||||
if (!string.IsNullOrEmpty(report.FileUrl))
|
||||
{
|
||||
var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/'));
|
||||
if (File.Exists(filePath)) File.Delete(filePath);
|
||||
}
|
||||
db.Reports.Remove(report);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, message = "已删除" });
|
||||
var report = await reports.GetReportAsync(userId, id, ct);
|
||||
return report == null
|
||||
? Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" })
|
||||
: Results.Ok(new { code = 0, data = report, 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 });
|
||||
});
|
||||
|
||||
// 上传 + AI 预解读(VLM 提取指标 → LLM 生成摘要)
|
||||
group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) =>
|
||||
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
|
||||
// 读 multipart form
|
||||
if (!http.Request.HasFormContentType)
|
||||
return Results.Ok(new { code = 400, message = "需要 multipart/form-data" });
|
||||
return Results.Ok(new { code = 400, data = (object?)null, message = "需要 multipart/form-data" });
|
||||
|
||||
var form = await http.Request.ReadFormAsync(ct);
|
||||
var file = form.Files.GetFile("file");
|
||||
if (file == null || file.Length == 0)
|
||||
return Results.Ok(new { code = 400, message = "未上传文件" });
|
||||
if (file == null)
|
||||
return Results.Ok(new { code = 400, data = (object?)null, message = "未上传文件" });
|
||||
|
||||
// 保存文件
|
||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
|
||||
Directory.CreateDirectory(uploadsDir);
|
||||
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||
var filePath = Path.Combine(uploadsDir, fileName);
|
||||
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
await file.CopyToAsync(stream, ct);
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await reports.UploadReportAsync(
|
||||
userId,
|
||||
new ReportUploadFile(file.FileName, file.Length, stream),
|
||||
ct);
|
||||
|
||||
var fileUrl = $"/uploads/reports/{fileName}";
|
||||
var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf";
|
||||
return Results.Ok(new { code = result.Code, data = result.Report, message = result.Message });
|
||||
});
|
||||
|
||||
// 创建报告记录
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
FileUrl = fileUrl,
|
||||
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image,
|
||||
Category = ReportCategory.Other,
|
||||
Status = ReportStatus.PendingDoctor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Reports.Add(report);
|
||||
await db.SaveChangesAsync(ct);
|
||||
group.MapPost("/{id:guid}/reanalyze", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var ok = await reports.ReanalyzeReportAsync(userId, id, ct);
|
||||
return ok
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = "已重新提交 AI 分析" })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "报告不存在或原始文件丢失" });
|
||||
});
|
||||
|
||||
// 异步 AI 分析(用独立 scope,不依赖请求生命周期)
|
||||
var reportId = report.Id;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var taskDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var taskVision = scope.ServiceProvider.GetRequiredService<VisionClient>();
|
||||
var taskLlm = scope.ServiceProvider.GetRequiredService<DeepSeekClient>();
|
||||
await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Report] AI 分析失败: {ex.Message}");
|
||||
// 回退:写入 fallback 数据
|
||||
try
|
||||
{
|
||||
using var fbScope = scopeFactory.CreateScope();
|
||||
var fbDb = fbScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
|
||||
if (fbReport != null)
|
||||
{
|
||||
fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category);
|
||||
fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category);
|
||||
await fbDb.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传,AI 正在分析中" });
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var ok = await reports.DeleteReportAsync(userId, id, ct);
|
||||
return ok
|
||||
? Results.Ok(new { code = 0, data = new { success = true }, message = "已删除" })
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "不存在" });
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VLM 识别 + LLM 解读
|
||||
/// </summary>
|
||||
private static async Task AnalyzeReportWithAI(
|
||||
Guid reportId, string filePath,
|
||||
VisionClient vision, DeepSeekClient llm, AppDbContext db)
|
||||
{
|
||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
|
||||
if (report == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
// ===== Step 1: 使用 VLM 识别报告图片,提取指标 =====
|
||||
var vlmPrompt = """
|
||||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
||||
|
||||
第一步:判断这到底是不是一份医学检验报告。
|
||||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
||||
{"isReport": false, "reason": "这不是医学报告,原因:..."}
|
||||
|
||||
如果是医学报告,提取所有检测指标,返回严格JSON格式(不要markdown包裹):
|
||||
{
|
||||
"isReport": true,
|
||||
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
|
||||
"indicators": [
|
||||
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
|
||||
]
|
||||
}
|
||||
|
||||
注意:
|
||||
- status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low
|
||||
- 如果参考范围不明确,根据常见医学标准判断
|
||||
""";
|
||||
|
||||
var vlmContent = "";
|
||||
try
|
||||
{
|
||||
var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf";
|
||||
var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath);
|
||||
var vlmResponse = await vision.VisionAsync(
|
||||
vlmPrompt,
|
||||
imageUrl != null ? [imageUrl] : [],
|
||||
userText: "请分析这份医学报告",
|
||||
maxTokens: 2048);
|
||||
|
||||
vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||||
Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}");
|
||||
// VLM 不可用时回退到 mock 数据
|
||||
vlmContent = GenerateFallbackIndicators(report.Category);
|
||||
}
|
||||
|
||||
// 解析 VLM 结果
|
||||
var vlmJson = TryParseJson(vlmContent);
|
||||
var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean();
|
||||
var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null;
|
||||
|
||||
if (!isReport && !string.IsNullOrEmpty(reason))
|
||||
{
|
||||
report.AiSummary = $"⚠️ 无法分析:{reason}";
|
||||
report.AiIndicators = "[]";
|
||||
report.Status = ReportStatus.PendingDoctor;
|
||||
}
|
||||
else
|
||||
{
|
||||
var indicatorsJson = "[]";
|
||||
var reportType = "Other";
|
||||
if (vlmJson != null)
|
||||
{
|
||||
reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other";
|
||||
if (vlmJson.Value.TryGetProperty("indicators", out var inds))
|
||||
indicatorsJson = inds.GetRawText();
|
||||
}
|
||||
|
||||
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
|
||||
report.AiIndicators = indicatorsJson;
|
||||
|
||||
// ===== Step 2: 使用 LLM 生成专业解读摘要 =====
|
||||
try
|
||||
{
|
||||
var indicators = indicatorsJson;
|
||||
var llmPrompt = $"""
|
||||
你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
||||
|
||||
检测指标:{indicators}
|
||||
|
||||
要求:
|
||||
1. 总字数200-300字
|
||||
2. 先总结整体情况
|
||||
3. 指出异常指标及其可能原因
|
||||
4. 给出生活建议
|
||||
5. 末尾提醒"以上为AI预解读,具体请以医生诊断为准"
|
||||
|
||||
只返回解读文本,不要JSON格式。
|
||||
""";
|
||||
|
||||
var llmMessages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" },
|
||||
new() { Role = "user", Content = llmPrompt }
|
||||
};
|
||||
|
||||
var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f);
|
||||
var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
||||
|
||||
if (!string.IsNullOrEmpty(summary))
|
||||
report.AiSummary = summary;
|
||||
else
|
||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}");
|
||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Console.WriteLine($"[Report] AI 分析完成: {reportId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Report] 分析异常: {ex.Message}");
|
||||
if (report != null)
|
||||
{
|
||||
report.AiSummary = GenerateFallbackSummary(report.Category);
|
||||
report.AiIndicators = GenerateFallbackIndicators(report.Category);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static async Task<string?> GetLocalImageUrl(string filePath)
|
||||
{
|
||||
// 对于本地文件,VLM 需要可访问的 URL
|
||||
// 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL
|
||||
// 这里简化:暂不支持 PDF,图片直接用本地路径(千问支持本地路径)
|
||||
if (!File.Exists(filePath)) return null;
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
var base64 = Convert.ToBase64String(bytes);
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
|
||||
var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" };
|
||||
return $"data:{mime};base64,{base64}";
|
||||
}
|
||||
|
||||
private static JsonElement? TryParseJson(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content)) return null;
|
||||
content = content.Trim();
|
||||
// 去掉可能包裹的 markdown ```json ... ```
|
||||
if (content.StartsWith("```"))
|
||||
{
|
||||
var end = content.IndexOf('\n');
|
||||
if (end > 0) content = content[(end + 1)..];
|
||||
if (content.EndsWith("```"))
|
||||
content = content[..^3];
|
||||
}
|
||||
content = content.Trim();
|
||||
try { return JsonDocument.Parse(content).RootElement; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string GenerateFallbackSummary(ReportCategory category) => category switch
|
||||
{
|
||||
ReportCategory.BloodTest => "血常规显示白细胞计数正常,红细胞及血红蛋白略低于参考范围下限,提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平,适当补充富含铁质的食物。以上为AI预解读,具体请以医生诊断为准。",
|
||||
ReportCategory.Ecg => "心电图显示窦性心律,心率正常。无明显ST段改变或心律失常。以上为AI预解读,具体请以医生诊断为准。",
|
||||
_ => "检查指标基本在参考范围内。以上为AI预解读,具体请以医生诊断为准。"
|
||||
};
|
||||
|
||||
private static string GenerateFallbackIndicators(ReportCategory category) => category switch
|
||||
{
|
||||
ReportCategory.BloodTest => """[{"name":"白细胞计数","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"红细胞计数","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"血红蛋白","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"血小板计数","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""",
|
||||
_ => """[{"name":"检查结果","value":"正常","unit":"","referenceRange":"","status":"normal"}]"""
|
||||
};
|
||||
|
||||
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 CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
||||
@@ -1,105 +1,58 @@
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.Users;
|
||||
|
||||
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) =>
|
||||
group.MapGet("/profile", async (HttpContext http, IUserService users, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var user = await db.Users.Select(u => new
|
||||
{
|
||||
u.Id, u.Phone, u.Role, 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 });
|
||||
var profile = await users.GetProfileAsync(GetUserId(http), ct);
|
||||
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
|
||||
});
|
||||
|
||||
// 修改资料
|
||||
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPut("/profile", async (UpdateProfileRequest req, HttpContext http, IUserService users, 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);
|
||||
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
var updated = await users.UpdateProfileAsync(
|
||||
GetUserId(http),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
|
||||
ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 获取健康档案
|
||||
group.MapGet("/health-archive", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
var archive = await archives.GetAsync(GetUserId(http), 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) =>
|
||||
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
if (archive == null)
|
||||
{
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.HealthArchives.Add(archive);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
await archives.UpdateAsync(
|
||||
GetUserId(http),
|
||||
new HealthArchiveUpdateRequest(
|
||||
req.Diagnosis,
|
||||
req.SurgeryType,
|
||||
surgeryDate,
|
||||
req.Allergies,
|
||||
req.DietRestrictions,
|
||||
req.ChronicDiseases,
|
||||
req.FamilyHistory),
|
||||
ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 注销账号
|
||||
group.MapDelete("/account", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapDelete("/account", async (HttpContext http, IUserService users, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
// 医生:清除Doctor关联
|
||||
var profile = await db.DoctorProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
if (profile?.DoctorId != null)
|
||||
{
|
||||
await db.Users.Where(u => u.DoctorId == profile!.DoctorId).ExecuteUpdateAsync(u => u.SetProperty(x => x.DoctorId, (Guid?)null), ct);
|
||||
await db.Doctors.Where(d => d.Id == profile.DoctorId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
// 删除所有关联数据
|
||||
await db.HealthRecords.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.MedicationLogs.Where(l => l.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.Medications.Where(m => m.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.DietFoodItems.Where(i => i.DietRecord!.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.DietRecords.Where(d => d.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.ExercisePlanItems.Where(i => i.Plan!.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.ExercisePlans.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.Reports.Where(r => r.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.ConversationMessages.Where(m => m.Conversation!.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.Conversations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.ConsultationMessages.Where(m => m.Consultation!.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.Consultations.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.DoctorProfiles.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync(ct);
|
||||
await users.DeleteAccountAsync(GetUserId(http), ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user