feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -0,0 +1,63 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed record AiConversationDto(
Guid Id,
string AgentType,
string? Title,
string? Summary,
int MessageCount,
DateTime CreatedAt,
DateTime UpdatedAt);
public sealed record AiConversationMessageDto(
Guid Id,
string Role,
string Content,
string? Intent,
string? MetadataJson,
DateTime CreatedAt);
public sealed record OpenConversationResult(
bool Found,
bool Created,
Guid ConversationId);
public interface IAiConversationService
{
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
}
public interface IAiConversationRepository
{
Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct);
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
void Delete(Conversation conversation);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IPatientContextService
{
Task<string> BuildAsync(Guid userId, CancellationToken ct);
}
public sealed record AiConfirmedWriteResult(int Code, object? Data, string? Message);
public interface IAiToolExecutionService
{
Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct);
Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct);
}

View File

@@ -0,0 +1,111 @@
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
Guid userId,
Guid? conversationId,
AgentType agentType,
string firstMessage,
CancellationToken ct)
{
if (conversationId.HasValue)
{
var existing = await _conversations.GetOwnedAsync(userId, conversationId.Value, ct);
return existing == null
? new OpenConversationResult(false, false, conversationId.Value)
: new OpenConversationResult(true, false, existing.Id);
}
var conversation = new Conversation
{
Id = Guid.NewGuid(),
UserId = userId,
AgentType = agentType,
Title = firstMessage.Length > 30 ? firstMessage[..30] : firstMessage,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{
var messages = await _conversations.GetRecentMessagesAsync(conversationId, limit, ct);
return messages.OrderBy(m => m.CreatedAt).Select(ToMessageDto).ToList();
}
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{
var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Select(ToDto).ToList();
}
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return [];
var messages = await _conversations.GetMessagesAsync(conversationId, ct);
return messages.Select(ToMessageDto).ToList();
}
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
if (conversation == null) return;
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
await _conversations.AddMessageAsync(new ConversationMessage
{
Id = Guid.NewGuid(),
ConversationId = conversationId,
Role = role,
Content = content,
CreatedAt = DateTime.UtcNow,
}, ct);
conversation.MessageCount++;
conversation.UpdatedAt = DateTime.UtcNow;
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct);
}
private static AiConversationDto ToDto(Conversation conversation) => new(
conversation.Id,
conversation.AgentType.ToString(),
conversation.Title,
conversation.Summary,
conversation.MessageCount,
conversation.CreatedAt,
conversation.UpdatedAt);
private static AiConversationMessageDto ToMessageDto(ConversationMessage message) => new(
message.Id,
message.Role.ToString(),
message.Content,
message.Intent,
message.MetadataJson,
message.CreatedAt);
}

View File

@@ -0,0 +1,15 @@
namespace Health.Application.AI;
public sealed record PendingAiWriteCommand(
Guid Id,
Guid UserId,
string ToolName,
string Arguments,
DateTime ExpiresAt);
public interface IAiWriteConfirmationStore
{
Task<PendingAiWriteCommand> CreateAsync(Guid userId, string toolName, string arguments, TimeSpan lifetime, CancellationToken ct);
Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct);
Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct);
}

View File

@@ -0,0 +1,65 @@
using System.Text;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
namespace Health.Application.AI;
public sealed class PatientContextService(
IHealthArchiveService archives,
IHealthRecordService healthRecords,
IMedicationService medications) : IPatientContextService
{
private readonly IHealthArchiveService _archives = archives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IMedicationService _medications = medications;
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
{
var archive = await _archives.GetAsync(userId, ct);
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
var activeMedications = await _medications.ListActiveAsync(userId, ct);
var sb = new StringBuilder();
if (archive != null)
{
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
}
if (activeMedications.Count > 0)
{
sb.AppendLine("当前用药:");
foreach (var medication in activeMedications.Take(20))
{
var times = medication.TimeOfDay.Count > 0
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
: "未设置时间";
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
}
}
if (recentRecords.Count > 0)
{
sb.AppendLine("近期健康数据:");
foreach (var record in recentRecords)
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
}
return sb.ToString();
}
private static string RecordValue(HealthRecordDto record) => record.Type switch
{
"BloodPressure" => $"{record.Systolic}/{record.Diastolic}",
"HeartRate" => $"{record.Value}次/分",
"Glucose" => $"{record.Value}",
"SpO2" => $"{record.Value}%",
"Weight" => $"{record.Value}kg",
_ => "—"
};
}