Commit 4981617b authored by yuguo's avatar yuguo

fix

parent 859fb6ac
...@@ -115,6 +115,8 @@ func main() { ...@@ -115,6 +115,8 @@ func main() {
&model.SafetyFilterLog{}, &model.SafetyFilterLog{},
// 通知 // 通知
&model.Notification{}, &model.Notification{},
// 合规报告
&model.ComplianceReport{},
// HTTP 动态工具 // HTTP 动态工具
&model.HTTPToolDefinition{}, &model.HTTPToolDefinition{},
// v15: SQL 动态工具 // v15: SQL 动态工具
...@@ -231,8 +233,10 @@ func main() { ...@@ -231,8 +233,10 @@ func main() {
authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment) authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment)
// 医生端路由(需要认证 + 医生角色) // 医生端路由(需要认证 + 医生角色)
doctorApi := authApi.Group("")
doctorApi.Use(middleware.RequireRole("doctor"))
doctorPortalHandler := doctorportal.NewHandler() doctorPortalHandler := doctorportal.NewHandler()
doctorPortalHandler.RegisterRoutes(authApi) doctorPortalHandler.RegisterRoutes(doctorApi)
// 健康档案路由(需要认证) // 健康档案路由(需要认证)
healthHandler := health.NewHandler() healthHandler := health.NewHandler()
...@@ -243,8 +247,10 @@ func main() { ...@@ -243,8 +247,10 @@ func main() {
chronicHandler.RegisterRoutes(authApi) chronicHandler.RegisterRoutes(authApi)
// 管理端路由(需要认证 + 管理员角色) // 管理端路由(需要认证 + 管理员角色)
adminApi := authApi.Group("")
adminApi.Use(middleware.RequireRole("admin"))
adminHandler := admin.NewHandler() adminHandler := admin.NewHandler()
adminHandler.RegisterRoutes(authApi) adminHandler.RegisterRoutes(adminApi)
// Agent路由(需要认证) // Agent路由(需要认证)
agentHandler := internalagent.NewHandler() agentHandler := internalagent.NewHandler()
......
package model
import "time"
// ComplianceReport 合规报告
type ComplianceReport struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Type string `gorm:"type:varchar(50)" json:"type"` // monthly=月度报告, special=专项报告, security=安全报告
Period string `gorm:"type:varchar(20)" json:"period"` // e.g. 2026-03
Status string `gorm:"type:varchar(20);default:pending" json:"status"` // pending, generated, submitted
FileURL string `gorm:"type:varchar(500)" json:"file_url"`
GeneratedAt *time.Time `json:"generated_at"`
SubmittedAt *time.Time `json:"submitted_at"`
CreatedBy string `gorm:"type:varchar(36)" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
This diff is collapsed.
This diff is collapsed.
...@@ -152,6 +152,11 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -152,6 +152,11 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 数据导出 // 数据导出
adm.POST("/export/:type", h.ExportData) adm.POST("/export/:type", h.ExportData)
// 合规报告
adm.GET("/reports", h.ListReports)
adm.POST("/reports/generate", h.GenerateReport)
adm.POST("/reports/:id/submit", h.SubmitReport)
} }
// 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问) // 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问)
...@@ -463,105 +468,3 @@ func (h *Handler) ResetAdminPassword(c *gin.Context) { ...@@ -463,105 +468,3 @@ func (h *Handler) ResetAdminPassword(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// GetSystemLogs 系统日志
func (h *Handler) GetSystemLogs(c *gin.Context) {
logs, err := h.service.GetSystemLogs(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取日志失败")
return
}
response.Success(c, logs)
}
// GetConsultList 管理后台获取问诊列表
func (h *Handler) GetConsultList(c *gin.Context) {
var params ConsultListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetConsultList(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取问诊列表失败")
return
}
response.Success(c, result)
}
// GetAIConfig 获取AI配置
func (h *Handler) GetAIConfig(c *gin.Context) {
cfg, err := h.service.GetAIConfig(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取AI配置失败")
return
}
// 对API Key脱敏
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// SaveAIConfig 保存AI配置
func (h *Handler) SaveAIConfig(c *gin.Context) {
var req SaveAIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
cfg, err := h.service.SaveAIConfig(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, "保存AI配置失败: "+err.Error())
return
}
// 脱敏返回
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// GetAIUsageStats 获取AI使用统计
func (h *Handler) GetAIUsageStats(c *gin.Context) {
stats, err := h.service.GetAIUsageStats(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取使用统计失败")
return
}
response.Success(c, stats)
}
// GetAILogs 获取AI调用日志
func (h *Handler) GetAILogs(c *gin.Context) {
var params AILogListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetAILogs(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取AI日志失败")
return
}
response.Success(c, result)
}
// ExportData 数据导出
func (h *Handler) ExportData(c *gin.Context) {
exportType := c.Param("type")
userID, _ := c.Get("user_id")
result, err := h.service.ExportData(c.Request.Context(), exportType, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
package admin
import (
"github.com/gin-gonic/gin"
"internet-hospital/pkg/response"
)
// GetSystemLogs 系统日志
func (h *Handler) GetSystemLogs(c *gin.Context) {
var params SystemLogParams
_ = c.ShouldBindQuery(&params)
logs, err := h.service.GetSystemLogsWithParams(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取日志失败")
return
}
response.Success(c, logs)
}
// GetConsultList 管理后台获取问诊列表
func (h *Handler) GetConsultList(c *gin.Context) {
var params ConsultListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetConsultList(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取问诊列表失败")
return
}
response.Success(c, result)
}
// GetAIConfig 获取AI配置
func (h *Handler) GetAIConfig(c *gin.Context) {
cfg, err := h.service.GetAIConfig(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取AI配置失败")
return
}
// 对API Key脱敏
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// SaveAIConfig 保存AI配置
func (h *Handler) SaveAIConfig(c *gin.Context) {
var req SaveAIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
cfg, err := h.service.SaveAIConfig(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, "保存AI配置失败: "+err.Error())
return
}
// 脱敏返回
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// GetAIUsageStats 获取AI使用统计
func (h *Handler) GetAIUsageStats(c *gin.Context) {
stats, err := h.service.GetAIUsageStats(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取使用统计失败")
return
}
response.Success(c, stats)
}
// GetAILogs 获取AI调用日志
func (h *Handler) GetAILogs(c *gin.Context) {
var params AILogListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetAILogs(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取AI日志失败")
return
}
response.Success(c, result)
}
// ExportData 数据导出
func (h *Handler) ExportData(c *gin.Context) {
exportType := c.Param("type")
userID, _ := c.Get("user_id")
result, err := h.service.ExportData(c.Request.Context(), exportType, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
// ListReports 合规报告列表
func (h *Handler) ListReports(c *gin.Context) {
reports, err := h.service.ListReports(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取报告列表失败")
return
}
response.Success(c, reports)
}
// GenerateReport 生成合规报告
func (h *Handler) GenerateReport(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Period string `json:"period" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请提供报告类型、名称和周期")
return
}
userID, _ := c.Get("user_id")
report, err := h.service.GenerateReport(c.Request.Context(), req.Type, req.Name, req.Period, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, report)
}
// SubmitReport 上报合规报告
func (h *Handler) SubmitReport(c *gin.Context) {
id := c.Param("id")
userID, _ := c.Get("user_id")
if err := h.service.SubmitReport(c.Request.Context(), id, userID.(string)); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
This diff is collapsed.
...@@ -11,6 +11,7 @@ import ( ...@@ -11,6 +11,7 @@ import (
internalagent "internet-hospital/internal/agent" internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -324,13 +325,29 @@ func (s *Service) DoctorListRenewals(ctx context.Context, doctorUserID string) ( ...@@ -324,13 +325,29 @@ func (s *Service) DoctorListRenewals(ctx context.Context, doctorUserID string) (
} }
func (s *Service) DoctorApproveRenewal(ctx context.Context, doctorUserID, renewalID string) error { func (s *Service) DoctorApproveRenewal(ctx context.Context, doctorUserID, renewalID string) error {
return s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{ if err := s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{
"status": "approved", "status": "approved",
}).Error }).Error; err != nil {
return err
}
// 通知患者续方已批准
var renewal model.RenewalRequest
if s.db.Where("id = ?", renewalID).First(&renewal).Error == nil {
go notification.Notify(renewal.UserID, "续方已批准", "您的续方申请已通过审核", "chronic", renewalID)
}
return nil
} }
func (s *Service) DoctorRejectRenewal(ctx context.Context, doctorUserID, renewalID, reason string) error { func (s *Service) DoctorRejectRenewal(ctx context.Context, doctorUserID, renewalID, reason string) error {
return s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{ if err := s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{
"status": "rejected", "doctor_note": reason, "status": "rejected", "doctor_note": reason,
}).Error }).Error; err != nil {
return err
}
// 通知患者续方被驳回
var renewal model.RenewalRequest
if s.db.Where("id = ?", renewalID).First(&renewal).Error == nil {
go notification.Notify(renewal.UserID, "续方未通过", fmt.Sprintf("您的续方申请未通过审核:%s", reason), "chronic", renewalID)
}
return nil
} }
This diff is collapsed.
...@@ -56,6 +56,12 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -56,6 +56,12 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 患者端处方 // 患者端处方
consult.GET("/patient/prescriptions", h.GetPatientPrescriptions) consult.GET("/patient/prescriptions", h.GetPatientPrescriptions)
consult.GET("/prescription/:id", h.GetPrescriptionDetail) consult.GET("/prescription/:id", h.GetPrescriptionDetail)
// 标记消息已读
consult.POST("/:id/mark-read", h.MarkMessagesRead)
// 患者评价
consult.POST("/:id/rate", h.RateConsult)
} }
} }
...@@ -544,3 +550,46 @@ func (h *Handler) RejectConsult(c *gin.Context) { ...@@ -544,3 +550,46 @@ func (h *Handler) RejectConsult(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
func (h *Handler) RateConsult(c *gin.Context) {
id := c.Param("id")
userID, _ := c.Get("user_id")
var req struct {
Rating int `json:"rating" binding:"required,min=1,max=5"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请提供有效的评分(1-5)")
return
}
if err := h.service.RateConsult(c.Request.Context(), id, fmt.Sprintf("%v", userID), req.Rating, req.Comment); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// MarkMessagesRead 标记问诊中对方发送的消息为已读
func (h *Handler) MarkMessagesRead(c *gin.Context) {
consultID := c.Param("id")
userID, _ := c.Get("user_id")
role, _ := c.Get("role")
// 患者标记医生/AI消息为已读,医生标记患者消息为已读
var senderTypes []string
if role == "patient" {
senderTypes = []string{"doctor", "ai", "system"}
} else {
senderTypes = []string{"patient"}
}
count, err := h.service.MarkMessagesRead(c.Request.Context(), consultID, fmt.Sprintf("%v", userID), senderTypes)
if err != nil {
response.Error(c, 500, "标记已读失败")
return
}
response.Success(c, gin.H{"marked_count": count})
}
package consult
import (
"context"
"fmt"
"time"
"internet-hospital/internal/model"
)
// ========== 患者画像 API (v13) ==========
// PatientBasicInfo 患者基本信息
type PatientBasicInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
AllergyHistory string `json:"allergy_history"`
MedicalHistory string `json:"medical_history"`
InsuranceType string `json:"insurance_type"`
InsuranceNo string `json:"insurance_no"`
}
// ConsultSummary 问诊摘要
type ConsultSummary struct {
ID string `json:"id"`
DoctorName string `json:"doctor_name"`
ChiefComplaint string `json:"chief_complaint"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at"`
}
// PrescriptionBrief 处方摘要
type PrescriptionBrief struct {
ID string `json:"id"`
PrescriptionNo string `json:"prescription_no"`
Diagnosis string `json:"diagnosis"`
ItemCount int `json:"item_count"`
TotalAmount int `json:"total_amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ChronicBrief 慢病摘要
type ChronicBrief struct {
ID string `json:"id"`
DiseaseName string `json:"disease_name"`
DiagnosisDate *time.Time `json:"diagnosis_date"`
ControlStatus string `json:"control_status"`
CurrentMeds string `json:"current_meds"`
}
// LabReportBrief 检查报告摘要
type LabReportBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
ReportDate *time.Time `json:"report_date"`
FileURL string `json:"file_url"`
}
// HealthMetricPoint 健康指标数据点
type HealthMetricPoint struct {
MetricType string `json:"metric_type"`
Value1 float64 `json:"value1"`
Value2 float64 `json:"value2"`
Unit string `json:"unit"`
RecordedAt time.Time `json:"recorded_at"`
}
// FamilyMemberBrief 家族成员摘要
type FamilyMemberBrief struct {
Name string `json:"name"`
Relation string `json:"relation"`
MedicalHistory string `json:"medical_history"`
}
// PatientProfileResponse 患者画像完整响应
type PatientProfileResponse struct {
BasicInfo PatientBasicInfo `json:"basic_info"`
ConsultHistory []ConsultSummary `json:"consult_history"`
Prescriptions []PrescriptionBrief `json:"prescriptions"`
ChronicRecords []ChronicBrief `json:"chronic_records"`
LabReports []LabReportBrief `json:"lab_reports"`
HealthMetrics []HealthMetricPoint `json:"health_metrics"`
FamilyHistory []FamilyMemberBrief `json:"family_history"`
}
// GetPatientProfile 获取患者完整画像
func (s *Service) GetPatientProfile(ctx context.Context, patientID string) (*PatientProfileResponse, error) {
profile := &PatientProfileResponse{}
// 基本信息
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
profile.BasicInfo = PatientBasicInfo{
ID: user.ID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
}
var patientProfile model.PatientProfile
if err := s.db.Where("user_id = ?", patientID).First(&patientProfile).Error; err == nil {
profile.BasicInfo.AllergyHistory = patientProfile.AllergyHistory
profile.BasicInfo.MedicalHistory = patientProfile.MedicalHistory
profile.BasicInfo.InsuranceType = patientProfile.InsuranceType
profile.BasicInfo.InsuranceNo = patientProfile.InsuranceNo
}
// 问诊历史(最近10条)
var consultations []model.Consultation
s.db.Where("patient_id = ? AND status = ?", patientID, "completed").
Order("created_at DESC").Limit(10).Find(&consultations)
profile.ConsultHistory = make([]ConsultSummary, 0, len(consultations))
for _, c := range consultations {
var doc model.Doctor
doctorName := ""
if err := s.db.Where("id = ?", c.DoctorID).First(&doc).Error; err == nil {
doctorName = doc.Name
}
profile.ConsultHistory = append(profile.ConsultHistory, ConsultSummary{
ID: c.ID,
DoctorName: doctorName,
ChiefComplaint: c.ChiefComplaint,
Type: c.Type,
Status: c.Status,
CreatedAt: c.CreatedAt,
EndedAt: c.EndedAt,
})
}
// 处方历史(最近10条)
var prescriptions []model.Prescription
s.db.Where("patient_id = ?", patientID).
Order("created_at DESC").Limit(10).Preload("Items").Find(&prescriptions)
profile.Prescriptions = make([]PrescriptionBrief, 0, len(prescriptions))
for _, p := range prescriptions {
profile.Prescriptions = append(profile.Prescriptions, PrescriptionBrief{
ID: p.ID,
PrescriptionNo: p.PrescriptionNo,
Diagnosis: p.Diagnosis,
ItemCount: len(p.Items),
TotalAmount: p.TotalAmount,
Status: p.Status,
CreatedAt: p.CreatedAt,
})
}
// 慢病档案
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&chronicRecords)
profile.ChronicRecords = make([]ChronicBrief, 0, len(chronicRecords))
for _, cr := range chronicRecords {
profile.ChronicRecords = append(profile.ChronicRecords, ChronicBrief{
ID: cr.ID,
DiseaseName: cr.DiseaseName,
DiagnosisDate: cr.DiagnosisDate,
ControlStatus: cr.ControlStatus,
CurrentMeds: cr.CurrentMeds,
})
}
// 检查报告(最近10条)
var labReports []model.LabReport
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("report_date DESC").Limit(10).Find(&labReports)
profile.LabReports = make([]LabReportBrief, 0, len(labReports))
for _, lr := range labReports {
profile.LabReports = append(profile.LabReports, LabReportBrief{
ID: lr.ID,
Title: lr.Title,
Category: lr.Category,
ReportDate: lr.ReportDate,
FileURL: lr.FileURL,
})
}
// 健康指标(最近30条)
var healthMetrics []model.HealthMetric
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("recorded_at DESC").Limit(30).Find(&healthMetrics)
profile.HealthMetrics = make([]HealthMetricPoint, 0, len(healthMetrics))
for _, hm := range healthMetrics {
profile.HealthMetrics = append(profile.HealthMetrics, HealthMetricPoint{
MetricType: hm.MetricType,
Value1: hm.Value1,
Value2: hm.Value2,
Unit: hm.Unit,
RecordedAt: hm.RecordedAt,
})
}
// 家族史
var familyMembers []model.FamilyMember
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&familyMembers)
profile.FamilyHistory = make([]FamilyMemberBrief, 0, len(familyMembers))
for _, fm := range familyMembers {
profile.FamilyHistory = append(profile.FamilyHistory, FamilyMemberBrief{
Name: fm.Name,
Relation: fm.Relation,
MedicalHistory: fm.MedicalHistory,
})
}
return profile, nil
}
This diff is collapsed.
package doctorportal
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"internet-hospital/internal/model"
)
// PatientSummary 患者概要
type PatientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Gender string `json:"gender"`
Phone string `json:"phone"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
}
// PatientListItem 患者列表项
type PatientListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
ChronicDiseases []string `json:"chronic_diseases"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
LastConsultAt string `json:"last_consult_at"`
}
// PatientConsultRecord 患者问诊记录
type PatientConsultRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Type string `json:"type"`
ChiefComplaint string `json:"chief_complaint"`
Status string `json:"status"`
}
// PatientPrescriptionRecord 患者处方记录
type PatientPrescriptionRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Diagnosis string `json:"diagnosis"`
Drugs []string `json:"drugs"`
Status string `json:"status"`
PrescriptionNo string `json:"prescription_no"`
}
// PatientDetail 患者详情
type PatientDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
EmergencyContact string `json:"emergency_contact"`
InsuranceType string `json:"insurance_type"`
ConsultationCount int `json:"consultation_count"`
Consults []PatientConsultRecord `json:"consults"`
Prescriptions []PatientPrescriptionRecord `json:"prescriptions"`
}
func (s *Service) GetPatientSummary(ctx context.Context, patientID string) (*PatientSummary, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientSummary{
ID: patientID,
Name: user.RealName,
Age: user.Age,
Gender: user.Gender,
Phone: user.Phone,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(count),
}, nil
}
// GetPatientList 获取医生的患者列表
func (s *Service) GetPatientList(ctx context.Context, doctorID string, keyword string, page, pageSize int) ([]PatientListItem, int64, error) {
// 查询与该医生有过问诊的患者 ID(去重)
var patientIDs []string
query := s.db.Model(&model.Consultation{}).
Where("doctor_id = ?", doctorID).
Distinct("patient_id").
Pluck("patient_id", &patientIDs)
if query.Error != nil {
return nil, 0, query.Error
}
if len(patientIDs) == 0 {
return []PatientListItem{}, 0, nil
}
// 查询用户基本信息
userQuery := s.db.Model(&model.User{}).Where("id IN ?", patientIDs)
if keyword != "" {
userQuery = userQuery.Where("real_name LIKE ? OR phone LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
userQuery.Count(&total)
var users []model.User
offset := (page - 1) * pageSize
if err := userQuery.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
// 批量查询 profile 和问诊统计
userIDList := make([]string, len(users))
for i, u := range users {
userIDList[i] = u.ID
}
var profiles []model.PatientProfile
s.db.Where("user_id IN ?", userIDList).Find(&profiles)
profileMap := make(map[string]model.PatientProfile)
for _, p := range profiles {
profileMap[p.UserID] = p
}
// 每位患者的问诊次数和最近问诊时间
type consultStat struct {
PatientID string
Count int64
LastConsultAt time.Time
}
var stats []consultStat
s.db.Model(&model.Consultation{}).
Select("patient_id, COUNT(*) as count, MAX(created_at) as last_consult_at").
Where("doctor_id = ? AND patient_id IN ?", doctorID, userIDList).
Group("patient_id").
Scan(&stats)
statMap := make(map[string]consultStat)
for _, st := range stats {
statMap[st.PatientID] = st
}
items := make([]PatientListItem, 0, len(users))
for _, u := range users {
profile := profileMap[u.ID]
stat := statMap[u.ID]
lastAt := ""
if !stat.LastConsultAt.IsZero() {
lastAt = stat.LastConsultAt.Format("2006-01-02")
}
items = append(items, PatientListItem{
ID: u.ID,
Name: u.RealName,
Gender: u.Gender,
Age: u.Age,
Phone: u.Phone,
Avatar: u.Avatar,
ChronicDiseases: []string{},
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(stat.Count),
LastConsultAt: lastAt,
})
}
return items, total, nil
}
// GetPatientDetail 获取患者详情(含问诊和处方记录)
func (s *Service) GetPatientDetail(ctx context.Context, doctorID, patientID string) (*PatientDetail, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
// 问诊记录(仅该医生与该患者的)
var consults []model.Consultation
s.db.Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&consults)
consultRecords := make([]PatientConsultRecord, 0, len(consults))
for _, c := range consults {
consultRecords = append(consultRecords, PatientConsultRecord{
ID: c.ID,
Date: c.CreatedAt.Format("2006-01-02"),
Type: c.Type,
ChiefComplaint: c.ChiefComplaint,
Status: c.Status,
})
}
// 处方记录
var prescriptions []model.Prescription
s.db.Preload("Items").
Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&prescriptions)
prescriptionRecords := make([]PatientPrescriptionRecord, 0, len(prescriptions))
for _, p := range prescriptions {
drugs := make([]string, 0, len(p.Items))
for _, item := range p.Items {
drugs = append(drugs, item.MedicineName)
}
prescriptionRecords = append(prescriptionRecords, PatientPrescriptionRecord{
ID: p.ID,
Date: p.CreatedAt.Format("2006-01-02"),
Diagnosis: p.Diagnosis,
Drugs: drugs,
Status: p.Status,
PrescriptionNo: p.PrescriptionNo,
})
}
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientDetail{
ID: patientID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
Avatar: user.Avatar,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
EmergencyContact: profile.EmergencyContact,
InsuranceType: profile.InsuranceType,
ConsultationCount: int(count),
Consults: consultRecords,
Prescriptions: prescriptionRecords,
}, nil
}
// SubmitCertification 提交医生资质认证
func (s *Service) SubmitCertification(ctx context.Context, userID string, req *SubmitCertificationRequest) error {
// 检查是否已经提交过认证
var existingReview model.DoctorReview
if err := s.db.Where("user_id = ? AND status = ?", userID, "pending").First(&existingReview).Error; err == nil {
return fmt.Errorf("您已提交过认证申请,请等待审核")
}
// 获取用户信息
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Errorf("用户不存在")
}
// 创建审核记录
review := &model.DoctorReview{
ID: uuid.New().String(),
UserID: userID,
Name: user.RealName,
Phone: user.Phone,
LicenseNo: req.LicenseNo,
Title: req.Title,
Hospital: req.Hospital,
DepartmentName: req.DepartmentName,
LicenseImage: req.LicenseImage,
QualificationImage: req.QualificationImage,
Status: "pending",
SubmittedAt: time.Now(),
}
return s.db.Create(review).Error
}
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
internalagent "internet-hospital/internal/agent" internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -167,6 +168,9 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req * ...@@ -167,6 +168,9 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *
tx.Commit() tx.Commit()
// 通知患者处方已开具
go notification.Notify(prescription.PatientID, "处方已开具", fmt.Sprintf("医生已为您开具处方 %s,请前往处方页面查看", prescriptionNo), "prescription", prescription.ID)
// 触发 prescription_created 工作流(异步) // 触发 prescription_created 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "prescription_created", map[string]interface{}{ workflow.GetEngine().TriggerByCategory(ctx, "prescription_created", map[string]interface{}{
"prescription_id": prescription.ID, "prescription_id": prescription.ID,
......
...@@ -48,3 +48,9 @@ func (s *Service) Create(ctx context.Context, userID, title, content, nType, rel ...@@ -48,3 +48,9 @@ func (s *Service) Create(ctx context.Context, userID, title, content, nType, rel
} }
return s.db.Create(n).Error return s.db.Create(n).Error
} }
// Notify 全局便捷函数,供其他 service 在业务事件中直接调用
func Notify(userID, title, content, nType, relatedID string) {
svc := NewService()
_ = svc.Create(context.Background(), userID, title, content, nType, relatedID)
}
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -105,6 +106,9 @@ func (s *Service) PayOrder(ctx context.Context, orderID, paymentMethod string) ( ...@@ -105,6 +106,9 @@ func (s *Service) PayOrder(ctx context.Context, orderID, paymentMethod string) (
s.createDoctorIncome(ctx, order.RelatedID, order.Amount, order.OrderType) s.createDoctorIncome(ctx, order.RelatedID, order.Amount, order.OrderType)
} }
// 通知用户支付成功
go notification.Notify(order.UserID, "支付成功", fmt.Sprintf("您的订单 %s 已支付成功", order.OrderNo), "payment", order.ID)
// 触发 payment_completed 工作流(异步) // 触发 payment_completed 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "payment_completed", map[string]interface{}{ workflow.GetEngine().TriggerByCategory(ctx, "payment_completed", map[string]interface{}{
"order_id": order.ID, "order_id": order.ID,
......
...@@ -46,10 +46,11 @@ type LoginRequest struct { ...@@ -46,10 +46,11 @@ type LoginRequest struct {
} }
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
User *model.User `json:"user"` User *model.User `json:"user"`
Menus []model.Menu `json:"menus"`
} }
func (s *Service) SendCode(ctx context.Context, phone string) error { func (s *Service) SendCode(ctx context.Context, phone string) error {
...@@ -174,11 +175,24 @@ func (s *Service) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, ...@@ -174,11 +175,24 @@ func (s *Service) Login(ctx context.Context, req *LoginRequest) (*LoginResponse,
return nil, err return nil, err
} }
// 查询用户菜单(基于角色)
var menus []model.Menu
s.db.Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active").Order("sort_order ASC")
}).Where("parent_id IS NULL AND status = ?", "active").Order("sort_order ASC").Find(&menus)
// 根据角色过滤菜单(简化版:admin看全部,其他角色过滤)
if user.Role != "admin" {
// 可以在这里添加基于角色的菜单过滤逻辑
// 暂时保持简单,返回所有菜单
}
return &LoginResponse{ return &LoginResponse{
AccessToken: tokenPair.AccessToken, AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken, RefreshToken: tokenPair.RefreshToken,
ExpiresIn: tokenPair.ExpiresIn, ExpiresIn: tokenPair.ExpiresIn,
User: &user, User: &user,
Menus: menus,
}, nil }, nil
} }
......
This diff is collapsed.
package ai
import (
"context"
"time"
)
// MockChatStream 模拟流式AI回复
func MockChatStream(ctx context.Context, messages []ChatMessage, onChunk func(content string) error) (string, error) {
chatResult, err := MockChat(ctx, messages)
if err != nil {
return "", err
}
fullReply := chatResult.Content
// 模拟逐字输出
runes := []rune(fullReply)
chunkSize := 3
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
if onChunk != nil {
if err := onChunk(chunk); err != nil {
return fullReply, err
}
}
// 模拟打字延迟
select {
case <-ctx.Done():
return string(runes[:end]), ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
return fullReply, nil
}
// MockChat 模拟AI回复(API Key未配置时使用)
func MockChat(ctx context.Context, messages []ChatMessage) (*ChatResult, error) {
// 从最后一条用户消息判断场景
lastUserMsg := ""
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUserMsg = messages[i].Content
break
}
}
_ = lastUserMsg
// 检查system prompt判断是哪个阶段
systemPrompt := ""
for _, m := range messages {
if m.Role == "system" {
systemPrompt = m.Content
break
}
}
var content string
if contains(systemPrompt, "追问") {
content = `{
"questions": [
"您的头痛是持续性的还是间歇性的?",
"头痛时是否伴有恶心、呕吐、视物模糊等症状?",
"您最近的血压测量值是多少?",
"头痛通常在一天中的什么时间加重?"
]
}`
} else if contains(systemPrompt, "综合分析") || contains(systemPrompt, "诊断") || contains(systemPrompt, "分析报告") {
content = `## 综合分析
根据您描述的症状,头痛伴乏力持续数天,这可能与多种原因有关。结合您的描述,初步考虑可能为紧张性头痛或血压相关的头痛。持续性头部胀痛并伴随疲劳乏力,需要进一步检查确认。
## 严重程度
moderate - 症状持续时间较长且影响日常生活,建议尽快就诊。
## 推荐科室
神经内科
## 病历摘要
患者主诉头痛、乏力数天。胀痛为主,偶有恶心,无呕吐。建议神经内科就诊,进一步排查病因。
## 就医建议
1. 建议尽快到神经内科就诊,进行详细检查
2. 注意监测血压变化
3. 避免情绪激动和过度劳累
4. 保证充足睡眠,规律作息`
} else if contains(systemPrompt, "预问诊助手") || contains(systemPrompt, "预问诊对话") {
content = "您好!我是AI预问诊助手,感谢您的描述。为了更好地了解您的情况,我想再问您几个问题:\n\n1. 您的症状大概是从什么时候开始的?是突然出现的还是逐渐加重的?\n2. 目前有没有服用什么药物或者采取什么措施来缓解?\n\n请您详细描述一下,这样我可以更准确地为您提供建议。"
} else {
content = `{"message": "AI分析完成"}`
}
// 模拟 token 计数(粗略估算)
promptTokens := len(systemPrompt) / 4
completionTokens := len(content) / 4
return &ChatResult{
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}, nil
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// CallParams 统一AI调用参数
type CallParams struct {
Scene string // 场景标识
UserID string // 用户ID
Messages []ChatMessage // 消息列表
RequestSummary string // 请求摘要(用于日志)
// 链路追踪字段(Agent调用时填写)
TraceID string // 链路追踪ID
AgentID string // 关联Agent ID
SessionID string // 关联会话ID
Iteration int // Agent第几轮迭代(0=非Agent调用)
}
// CallResult 统一AI调用结果
type CallResult struct {
Content string
IsMock bool
ResponseTimeMs int64
PromptTokens int
CompletionTokens int
TotalTokens int
Error error
}
// Call 统一AI调用接口(自动记录日志)
func Call(ctx context.Context, params CallParams) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
chatResult, err := client.Chat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = false
} else {
chatResult, err := MockChat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
// CallStream 统一AI流式调用接口(自动记录日志)
func CallStream(ctx context.Context, params CallParams, onChunk func(content string) error) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
content, err := client.ChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = false
} else {
content, err := MockChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 流式调用没有返回 token 信息,粗略估算
promptLen := 0
for _, msg := range params.Messages {
promptLen += len(msg.Content)
}
result.PromptTokens = promptLen / 4
result.CompletionTokens = len(result.Content) / 4
result.TotalTokens = result.PromptTokens + result.CompletionTokens
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
This diff is collapsed.
...@@ -14,6 +14,7 @@ import ( ...@@ -14,6 +14,7 @@ import (
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/utils"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
...@@ -105,8 +106,21 @@ func (h *Handler) HandleConsultWS(c *gin.Context) { ...@@ -105,8 +106,21 @@ func (h *Handler) HandleConsultWS(c *gin.Context) {
return return
} }
// TODO: 验证token // 验证JWT token
_ = token if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := utils.ParseToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
// 校验token中的user_id与请求参数一致
if claims.UserID != userID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token user mismatch"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
......
This diff is collapsed.
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
......
...@@ -79,6 +79,19 @@ export interface SystemLog { ...@@ -79,6 +79,19 @@ export interface SystemLog {
created_at: string; created_at: string;
} }
export interface ComplianceReport {
id: string;
name: string;
type: string;
period: string;
status: 'pending' | 'generated' | 'submitted';
file_url: string;
generated_at: string | null;
submitted_at: string | null;
created_by: string;
created_at: string;
}
// 用户编辑/创建数据类型 // 用户编辑/创建数据类型
export interface UpdateUserData { export interface UpdateUserData {
real_name?: string; real_name?: string;
...@@ -400,4 +413,14 @@ export const adminApi = { ...@@ -400,4 +413,14 @@ export const adminApi = {
// === 数据导出 === // === 数据导出 ===
exportData: (type: string) => exportData: (type: string) =>
post<{ url: string; filename: string }>(`/admin/export/${type}`, {}), post<{ url: string; filename: string }>(`/admin/export/${type}`, {}),
// === 合规报告 ===
listReports: () =>
get<ComplianceReport[]>('/admin/reports'),
generateReport: (params: { type: string; name: string; period: string }) =>
post<ComplianceReport>('/admin/reports/generate', params),
submitReport: (id: string) =>
post<null>(`/admin/reports/${id}/submit`, {}),
}; };
...@@ -247,6 +247,14 @@ export const consultApi = { ...@@ -247,6 +247,14 @@ export const consultApi = {
cancelConsult: (idOrSerial: string, reason?: string) => cancelConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${idOrSerial}/cancel`, { reason }), post<null>(`/consult/${idOrSerial}/cancel`, { reason }),
// 标记消息已读
markMessagesRead: (idOrSerial: string) =>
post<{ marked_count: number }>(`/consult/${idOrSerial}/mark-read`, {}),
// 评价问诊
rateConsult: (idOrSerial: string, data: { rating: number; comment?: string }) =>
post<null>(`/consult/${idOrSerial}/rate`, data),
// v13: 上传多媒体文件(支持 consult_id 或 serial_number) // v13: 上传多媒体文件(支持 consult_id 或 serial_number)
uploadMedia: (idOrSerial: string, file: File) => { uploadMedia: (idOrSerial: string, file: File) => {
const formData = new FormData(); const formData = new FormData();
......
import axios from 'axios'; import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { useUserStore } from '@/store/userStore';
// 统一响应格式 // 统一响应格式
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
...@@ -30,10 +31,27 @@ const request: AxiosInstance = axios.create({ ...@@ -30,10 +31,27 @@ const request: AxiosInstance = axios.create({
}, },
}); });
// Token 刷新状态管理
let isRefreshing = false;
let pendingRequests: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach(({ resolve }) => resolve(newToken));
pendingRequests = [];
}
function onRefreshFailed(error: unknown) {
pendingRequests.forEach(({ reject }) => reject(error));
pendingRequests = [];
}
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem('access_token'); const token = useUserStore.getState().accessToken || localStorage.getItem('access_token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
...@@ -54,14 +72,74 @@ request.interceptors.response.use( ...@@ -54,14 +72,74 @@ request.interceptors.response.use(
} }
return response; return response;
}, },
(error) => { async (error) => {
if (error.response?.status === 401) { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Token 过期,清除登录状态
localStorage.removeItem('access_token'); // 非 401 或已重试过,直接拒绝
localStorage.removeItem('refresh_token'); if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
// 刷新 token 的请求本身 401,直接登出
if (originalRequest.url?.includes('/user/refresh-token')) {
useUserStore.getState().logout();
window.location.href = '/login'; window.location.href = '/login';
return Promise.reject(error);
}
const refreshToken = useUserStore.getState().refreshToken || localStorage.getItem('refresh_token');
if (!refreshToken) {
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(error);
}
// 如果已经在刷新中,排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({
resolve: (token: string) => {
originalRequest._retry = true;
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(request(originalRequest));
},
reject,
});
});
}
// 开始刷新
isRefreshing = true;
originalRequest._retry = true;
try {
const res = await axios.post(
`${getBaseURL()}/user/refresh-token`,
{ refresh_token: refreshToken },
);
const newAccessToken = res.data?.data?.access_token;
if (!newAccessToken) {
throw new Error('refresh failed');
}
// 更新 store 和 localStorage
localStorage.setItem('access_token', newAccessToken);
useUserStore.setState({ accessToken: newAccessToken });
// 重放排队的请求
onTokenRefreshed(newAccessToken);
// 重放原始请求
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return request(originalRequest);
} catch (refreshError) {
onRefreshFailed(refreshError);
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
return Promise.reject(error);
} }
); );
......
...@@ -57,6 +57,20 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) { ...@@ -57,6 +57,20 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [user]); }, [user]);
// AI浮窗导航事件
useEffect(() => {
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
let path = detail.page;
if (!path.startsWith('/')) path = '/' + path;
router.push(path);
}
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router]);
const handleBellClick = () => { const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {}); notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
}; };
......
...@@ -78,6 +78,20 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) { ...@@ -78,6 +78,20 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [user]); }, [user]);
// AI浮窗导航事件
useEffect(() => {
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
let path = detail.page;
if (!path.startsWith('/')) path = '/' + path;
router.push(path);
}
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router]);
const handleBellClick = () => { const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {}); notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
}; };
......
...@@ -457,11 +457,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => { ...@@ -457,11 +457,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
/> />
)} )}
</div> </div>
{role === 'doctor' && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4, textAlign: 'center' }}>
AI建议仅供参考,请结合临床实际情况
</div>
)}
</div> </div>
{/* Blinking cursor CSS */} {/* Blinking cursor CSS */}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment