feat(server-go): 专家会诊(病例快照/状态流转/意见与方案,#18)
This commit is contained in:
@@ -116,6 +116,7 @@ func main() {
|
|||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
|
|
||||||
// 启动高发病天气预警定时任务(未配置时跳过)
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
&model.LampTest{}, &model.LampTestStep{},
|
&model.LampTest{}, &model.LampTestStep{},
|
||||||
&model.Consumable{},
|
&model.Consumable{},
|
||||||
|
&model.Consultation{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"},
|
{"lamp:write", "LAMP 检测管理", "新建、编辑、录入 LAMP 检测结果"},
|
||||||
{"consumable:read", "耗材查看", "查看耗材库存与预警"},
|
{"consumable:read", "耗材查看", "查看耗材库存与预警"},
|
||||||
{"consumable:write", "耗材管理", "新增、编辑、删除耗材"},
|
{"consumable:write", "耗材管理", "新增、编辑、删除耗材"},
|
||||||
|
{"consultation:read", "会诊查看", "查看专家会诊单与病例快照"},
|
||||||
|
{"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
|
"consultation:read", "consultation:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -69,6 +72,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
|
"consultation:read", "consultation:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -80,6 +84,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read",
|
"lamp:read",
|
||||||
"consumable:read",
|
"consumable:read",
|
||||||
|
"consultation:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -91,5 +96,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"weather:read",
|
"weather:read",
|
||||||
"lamp:read",
|
"lamp:read",
|
||||||
"consumable:read",
|
"consumable:read",
|
||||||
|
"consultation:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user