diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index e2a215a..abbc00d 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -116,6 +116,7 @@ func main() { handler.RegisterWeatherRoutes(api, db, weatherSvc) handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) handler.RegisterConsumableRoutes(api, db) + handler.RegisterConsultationRoutes(api, db) // 启动高发病天气预警定时任务(未配置时跳过) go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 78654bf..7b87e73 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -34,6 +34,7 @@ func Init(cfg *config.Config) error { &model.WeatherAlert{}, &model.LampTest{}, &model.LampTestStep{}, &model.Consumable{}, + &model.Consultation{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/consultation.go b/server-go/internal/handler/consultation.go new file mode 100644 index 0000000..83fb19b --- /dev/null +++ b/server-go/internal/handler/consultation.go @@ -0,0 +1,253 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RegisterConsultationRoutes 注册专家会诊路由 +func RegisterConsultationRoutes(rg *gin.RouterGroup, db *gorm.DB) { + read := middleware.RequirePermission(db, "consultation:read") + write := middleware.RequirePermission(db, "consultation:write") + rg.GET("/consultations", read, listConsultations(db)) + rg.GET("/consultations/:id", read, getConsultation(db)) + rg.POST("/consultations", write, createConsultation(db)) + rg.PATCH("/consultations/:id", write, updateConsultation(db)) + rg.POST("/consultations/:id/resolve", write, resolveConsultation(db)) + rg.POST("/consultations/:id/archive", write, archiveConsultation(db)) +} + +// listConsultations 会诊单列表(status 过滤) +func listConsultations(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.Consultation{}) + if status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + var list []model.Consultation + q.Order("created_at DESC").Find(&list) + fillConsultationRoomNames(db, list) + c.JSON(http.StatusOK, list) + } +} + +func fillConsultationRoomNames(db *gorm.DB, list []model.Consultation) { + var rooms []model.Room + db.Select("id", "name").Find(&rooms) + names := make(map[string]string, len(rooms)) + for _, r := range rooms { + names[r.ID] = r.Name + } + for i := range list { + if list[i].RoomID != nil { + if n, ok := names[*list[i].RoomID]; ok { + list[i].RoomName = &n + } + } + } +} + +// getConsultation 会诊单详情 +func getConsultation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var t model.Consultation + if db.Where("id = ?", c.Param("id")).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) + return + } + fillConsultationRoomNames(db, []model.Consultation{t}) + c.JSON(http.StatusOK, t) + } +} + +// createConsultation 新建会诊单(可关联 LAMP 任务,自动打包病例快照) +func createConsultation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + Title string `json:"title"` + Summary *string `json:"summary"` + RoomID *string `json:"roomId"` + BatchID *string `json:"batchId"` + LampTestID *string `json:"lampTestId"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + for _, id := range []*string{body.RoomID, body.BatchID, body.LampTestID} { + if id != nil && *id != "" && !isUUID(*id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"}) + return + } + } + + rec := model.Consultation{ + RoomID: body.RoomID, + BatchID: body.BatchID, + LampTestID: body.LampTestID, + Summary: body.Summary, + Status: "pending", + } + if body.Title != "" { + rec.Title = body.Title + } else { + rec.Title = "LAMP 交叉验证会诊" + if body.Summary != nil { + rec.Title = "专家会诊:" + *body.Summary + } + } + + // 病例快照 + var lamp *model.LampTest + if body.LampTestID != nil && *body.LampTestID != "" { + var lt model.LampTest + if db.Where("id = ?", *body.LampTestID).First(<).Error == nil { + lamp = < + if rec.RoomID == nil { + rec.RoomID = lt.RoomID + } + if rec.BatchID == nil { + rec.BatchID = lt.BatchID + } + } + } + snapshot := buildConsultationSnapshot(db, rec.RoomID, rec.BatchID, lamp) + raw, _ := json.Marshal(snapshot) + rec.Snapshot = raw + + if err := db.Create(&rec).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"}) + return + } + c.JSON(http.StatusCreated, rec) + } +} + +// buildConsultationSnapshot 打包病例快照(房间/巡检/LAMP/批次/天气) +func buildConsultationSnapshot(db *gorm.DB, roomID, batchID *string, lamp *model.LampTest) model.ConsultationSnapshot { + s := model.ConsultationSnapshot{CreatedAt: time.Now()} + if roomID != nil { + var room model.Room + if db.Where("id = ?", *roomID).First(&room).Error == nil { + s.RoomName = room.Name + } + var insp model.InspectionRecord + if db.Where("room_id = ?", *roomID).Order("created_at DESC").First(&insp).Error == nil { + s.Inspection = &insp + } + var alerts []model.WeatherAlert + db.Order("created_at DESC").Limit(3).Find(&alerts) + s.WeatherAlerts = alerts + } + if lamp != nil { + s.LampTest = lamp + } + if batchID != nil { + var b model.Batch + if db.Where("id = ?", *batchID).First(&b).Error == nil { + s.Batch = &b + } + } else if lamp != nil && lamp.BatchID != nil { + var b model.Batch + if db.Where("id = ?", *lamp.BatchID).First(&b).Error == nil { + s.Batch = &b + } + } + return s +} + +// updateConsultation 更新会诊单(标题/摘要/状态流转) +func updateConsultation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.Consultation + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) + return + } + updates, err := bindUpdates(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if s, ok := updates["status"]; ok { + to, _ := s.(string) + if !model.ValidConsultationTransition(t.Status, to) { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"}) + return + } + } + if len(updates) > 0 { + db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates) + } + db.Where("id = ?", id).First(&t) + c.JSON(http.StatusOK, t) + } +} + +// resolveConsultation 专家出会诊意见与防控方案 +func resolveConsultation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.Consultation + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) + return + } + if !model.ValidConsultationTransition(t.Status, "resolved") { + c.JSON(http.StatusBadRequest, gin.H{"error": "当前状态不能出方案"}) + return + } + var body struct { + Opinion string `json:"opinion"` + Plan string `json:"plan"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Plan == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "防控方案不能为空"}) + return + } + now := time.Now() + updates := map[string]interface{}{ + "status": "resolved", + "expert_id": currentUserID(c), + "opinion": body.Opinion, + "plan": body.Plan, + "resolved_at": now, + } + db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates) + db.Where("id = ?", id).First(&t) + c.JSON(http.StatusOK, t) + } +} + +// archiveConsultation 归档 +func archiveConsultation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.Consultation + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "consultation not found"}) + return + } + if !model.ValidConsultationTransition(t.Status, "archived") { + c.JSON(http.StatusBadRequest, gin.H{"error": "仅已出方案的会诊可归档"}) + return + } + now := time.Now() + db.Model(&model.Consultation{}).Where("id = ?", id). + Updates(map[string]interface{}{"status": "archived", "archived_at": now}) + db.Where("id = ?", id).First(&t) + c.JSON(http.StatusOK, t) + } +} diff --git a/server-go/internal/model/consultation.go b/server-go/internal/model/consultation.go new file mode 100644 index 0000000..f0c62b6 --- /dev/null +++ b/server-go/internal/model/consultation.go @@ -0,0 +1,52 @@ +package model + +import ( + "encoding/json" + "time" +) + +// Consultation 专家会诊单 +type Consultation struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"` + Title string `gorm:"size:128" json:"title"` + Summary *string `gorm:"type:text" json:"summary,omitempty"` + Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"` + Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived + ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"` + Opinion *string `gorm:"type:text" json:"opinion,omitempty"` + Plan *string `gorm:"type:text" json:"plan,omitempty"` + ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"` + ArchivedAt *time.Time `gorm:"column:archived_at;type:timestamptz" json:"archivedAt,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + RoomName *string `gorm:"-" json:"roomName,omitempty"` +} + +func (Consultation) TableName() string { return "consultations" } + +// ValidConsultationTransition 状态流转规则:pending→consulting/resolved;consulting→resolved;resolved→archived +func ValidConsultationTransition(from, to string) bool { + switch from { + case "pending": + return to == "consulting" || to == "resolved" + case "consulting": + return to == "resolved" + case "resolved": + return to == "archived" + default: + return false + } +} + +// ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化) +type ConsultationSnapshot struct { + RoomName string `json:"roomName,omitempty"` + LampTest *LampTest `json:"lampTest,omitempty"` + Inspection *InspectionRecord `json:"inspection,omitempty"` + Batch *Batch `json:"batch,omitempty"` + WeatherAlerts []WeatherAlert `json:"weatherAlerts,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/server-go/internal/model/consultation_test.go b/server-go/internal/model/consultation_test.go new file mode 100644 index 0000000..c54a8da --- /dev/null +++ b/server-go/internal/model/consultation_test.go @@ -0,0 +1,57 @@ +package model + +import ( + "encoding/json" + "testing" + "time" +) + +func TestValidConsultationTransition(t *testing.T) { + ok := [][2]string{ + {"pending", "consulting"}, + {"pending", "resolved"}, + {"consulting", "resolved"}, + {"resolved", "archived"}, + } + for _, c := range ok { + if !ValidConsultationTransition(c[0], c[1]) { + t.Errorf("%s → %s 应合法", c[0], c[1]) + } + } + bad := [][2]string{ + {"pending", "archived"}, + {"resolved", "resolved"}, + {"archived", "pending"}, + {"", "resolved"}, + } + for _, c := range bad { + if ValidConsultationTransition(c[0], c[1]) { + t.Errorf("%s → %s 不应合法", c[0], c[1]) + } + } +} + +func TestConsultationSnapshotJSON(t *testing.T) { + now := time.Now() + s := ConsultationSnapshot{ + RoomName: "蚕房1#", + LampTest: &LampTest{ID: "lamp-1", Status: "resulted"}, + Inspection: &InspectionRecord{ID: "insp-1", AIStatus: "done"}, + Batch: &Batch{ID: "batch-1", Name: "批次A"}, + WeatherAlerts: []WeatherAlert{{Disease: "白僵病", Level: "orange"}}, + CreatedAt: now, + } + raw, err := json.Marshal(s) + if err != nil { + t.Fatalf("快照序列化失败: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("快照反序列化失败: %v", err) + } + for _, key := range []string{"roomName", "lampTest", "inspection", "batch", "weatherAlerts", "createdAt"} { + if _, ok := m[key]; !ok { + t.Errorf("快照缺少字段 %s", key) + } + } +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 91ca691..95f9f65 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -39,6 +39,8 @@ var AllPermissions = []PermissionDef{ {"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"}, {"consumable:read", "耗材查看", "查看耗材库存与预警"}, {"consumable:write", "耗材管理", "新增、编辑、删除耗材"}, + {"consultation:read", "会诊查看", "查看专家会诊单与病例快照"}, + {"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -56,6 +58,7 @@ var RolePermissionMap = map[string][]string{ "weather:read", "lamp:read", "lamp:write", "consumable:read", "consumable:write", + "consultation:read", "consultation:write", "user:manage", "audit:read", }, RoleOperator: { @@ -69,6 +72,7 @@ var RolePermissionMap = map[string][]string{ "weather:read", "lamp:read", "lamp:write", "consumable:read", "consumable:write", + "consultation:read", "consultation:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -80,6 +84,7 @@ var RolePermissionMap = map[string][]string{ "weather:read", "lamp:read", "consumable:read", + "consultation:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -91,5 +96,6 @@ var RolePermissionMap = map[string][]string{ "weather:read", "lamp:read", "consumable:read", + "consultation:read", }, }