feat(server-go): 疫病溯源三级模型与接口(#21,一级自动溯源/二级清单/三级备注)
This commit is contained in:
@@ -118,6 +118,7 @@ func main() {
|
|||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
handler.RegisterDetectionMethodRoutes(api, db)
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
|
handler.RegisterTraceRoutes(api, db)
|
||||||
|
|
||||||
// 启动高发病天气预警定时任务(未配置时跳过)
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.Consumable{},
|
&model.Consumable{},
|
||||||
&model.Consultation{},
|
&model.Consultation{},
|
||||||
&model.SpectrumEntry{},
|
&model.SpectrumEntry{},
|
||||||
|
&model.TraceRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterTraceRoutes 注册疫病溯源路由
|
||||||
|
func RegisterTraceRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "trace:read")
|
||||||
|
write := middleware.RequirePermission(db, "trace:write")
|
||||||
|
rg.GET("/trace-records", read, listTraceRecords(db))
|
||||||
|
rg.GET("/trace-records/:id", read, getTraceRecord(db))
|
||||||
|
rg.POST("/trace-records", write, createTraceRecord(db))
|
||||||
|
rg.PATCH("/trace-records/:id", write, updateTraceRecord(db))
|
||||||
|
rg.DELETE("/trace-records/:id", write, deleteTraceRecord(db))
|
||||||
|
rg.POST("/trace-records/:id/auto", write, autoTrace(db))
|
||||||
|
rg.GET("/trace-records/:id/checklist", read, getTraceChecklist(db))
|
||||||
|
rg.POST("/trace-records/:id/checklist", write, submitTraceChecklist(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTraceRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.TraceRecord{})
|
||||||
|
if status := c.Query("status"); status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if room := c.Query("roomId"); room != "" {
|
||||||
|
q = q.Where("room_id = ?", room)
|
||||||
|
}
|
||||||
|
var list []model.TraceRecord
|
||||||
|
q.Order("created_at DESC").Find(&list)
|
||||||
|
fillTraceRoomNames(db, list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillTraceRoomNames(db *gorm.DB, list []model.TraceRecord) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillTraceRoomNames(db, []model.TraceRecord{t})
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
RoomID *string `json:"roomId"`
|
||||||
|
LampTestID *string `json:"lampTestId"`
|
||||||
|
ConsultationID *string `json:"consultationId"`
|
||||||
|
Disease string `json:"disease"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.RoomID, body.LampTestID, body.ConsultationID} {
|
||||||
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Disease == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rec := model.TraceRecord{
|
||||||
|
RoomID: body.RoomID, LampTestID: body.LampTestID,
|
||||||
|
ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending",
|
||||||
|
}
|
||||||
|
if err := db.Create(&rec).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates, err := bindUpdates(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
db.Model(&model.TraceRecord{}).Where("id = ?", id).Updates(updates)
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).First(&t)
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Where("id = ?", id).Delete(&model.TraceRecord{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoTrace 一级自动溯源:环境回溯 + 历史关联 + 传播推断 → 溯源初报
|
||||||
|
func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report := map[string]interface{}{
|
||||||
|
"disease": t.Disease,
|
||||||
|
"generatedAt": time.Now(),
|
||||||
|
}
|
||||||
|
origin := "unknown"
|
||||||
|
confidence := 0.4
|
||||||
|
|
||||||
|
// 环境回溯(房间最近温湿度)
|
||||||
|
var samples []service.EnvSample
|
||||||
|
if t.RoomID != nil {
|
||||||
|
var room model.Room
|
||||||
|
if db.Where("id = ?", *t.RoomID).First(&room).Error == nil {
|
||||||
|
report["roomName"] = room.Name
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
Metric string `gorm:"column:metric"`
|
||||||
|
Value float64 `gorm:"column:value"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
db.Table("telemetry").
|
||||||
|
Select("telemetry.metric, telemetry.value").
|
||||||
|
Joins("JOIN devices ON devices.device_key = telemetry.device_key AND devices.room_id = ?", *t.RoomID).
|
||||||
|
Where("telemetry.metric IN ?", []string{"humidity", "temperature"}).
|
||||||
|
Order("telemetry.timestamp DESC").
|
||||||
|
Limit(40).
|
||||||
|
Scan(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
s := service.EnvSample{}
|
||||||
|
if r.Metric == "humidity" {
|
||||||
|
s.Humidity = &r.Value
|
||||||
|
} else {
|
||||||
|
s.Temp = &r.Value
|
||||||
|
}
|
||||||
|
samples = append(samples, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env := service.EnvironmentBacktrack(samples, 80)
|
||||||
|
report["environment"] = env
|
||||||
|
|
||||||
|
// 历史发病关联(同房间同病种 LAMP 记录)
|
||||||
|
pastCount := int64(0)
|
||||||
|
var past []service.PastEvent
|
||||||
|
if t.RoomID != nil {
|
||||||
|
raw, _ := json.Marshal([]string{t.Disease})
|
||||||
|
db.Model(&model.LampTest{}).
|
||||||
|
Where("room_id = ? AND status = 'resulted' AND diseases @> ?", *t.RoomID, string(raw)).
|
||||||
|
Count(&pastCount)
|
||||||
|
}
|
||||||
|
for i := int64(0); i < pastCount && i < 5; i++ {
|
||||||
|
past = append(past, service.PastEvent{Disease: t.Disease, DaysAgo: 15})
|
||||||
|
}
|
||||||
|
continuous := service.HistoryAssociation(past, t.Disease)
|
||||||
|
report["history"] = map[string]interface{}{
|
||||||
|
"continuous": continuous,
|
||||||
|
"pastCount": pastCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 传播途径推断
|
||||||
|
mode, source := service.TransmissionInference(t.Disease)
|
||||||
|
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
||||||
|
|
||||||
|
// 初步来源判定
|
||||||
|
if continuous {
|
||||||
|
origin = "internal"
|
||||||
|
confidence = 0.7
|
||||||
|
} else if env.HighHumidity || env.TempSwing {
|
||||||
|
origin = "internal"
|
||||||
|
confidence = 0.6
|
||||||
|
}
|
||||||
|
report["origin"] = origin
|
||||||
|
report["confidence"] = confidence
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(report)
|
||||||
|
db.Model(&model.TraceRecord{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"auto_report": raw, "origin": origin, "confidence": confidence, "status": "reported",
|
||||||
|
})
|
||||||
|
db.Where("id = ?", id).First(&t)
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTraceChecklist 分病种二级排查清单
|
||||||
|
func getTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, service.DiseaseChecklist(t.Disease))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitTraceChecklist 二级:提交排查清单 → 溯源分析报告
|
||||||
|
func submitTraceChecklist(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var t model.TraceRecord
|
||||||
|
if db.Where("id = ?", id).First(&t).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "trace record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Answers []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
} `json:"answers"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
template := service.DiseaseChecklist(t.Disease)
|
||||||
|
byKey := make(map[string]service.ChecklistItem, len(template))
|
||||||
|
for _, item := range template {
|
||||||
|
byKey[item.Key] = item
|
||||||
|
}
|
||||||
|
var answers []service.ChecklistAnswer
|
||||||
|
for _, a := range body.Answers {
|
||||||
|
item, ok := byKey[a.Key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
answers = append(answers, service.ChecklistAnswer{
|
||||||
|
Key: item.Key, Label: item.Label, Answer: a.Answer, InternalBias: item.InternalBias,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
origin, confidence, conclusion := service.AnalyzeChecklist(answers)
|
||||||
|
report := map[string]interface{}{
|
||||||
|
"origin": origin, "confidence": confidence, "conclusion": conclusion,
|
||||||
|
"answers": answers, "generatedAt": time.Now(),
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(report)
|
||||||
|
db.Model(&model.TraceRecord{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"analysis_report": raw, "origin": origin, "confidence": confidence, "status": "analysis",
|
||||||
|
})
|
||||||
|
db.Where("id = ?", id).First(&t)
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"consumable:write", "耗材管理", "新增、编辑、删除耗材"},
|
{"consumable:write", "耗材管理", "新增、编辑、删除耗材"},
|
||||||
{"consultation:read", "会诊查看", "查看专家会诊单与病例快照"},
|
{"consultation:read", "会诊查看", "查看专家会诊单与病例快照"},
|
||||||
{"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"},
|
{"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"},
|
||||||
|
{"trace:read", "溯源查看", "查看疫病溯源记录与报告"},
|
||||||
|
{"trace:write", "溯源管理", "发起溯源、执行排查清单与报告"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
"consultation:read", "consultation:write",
|
"consultation:read", "consultation:write",
|
||||||
|
"trace:read", "trace:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -73,6 +76,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"lamp:read", "lamp:write",
|
"lamp:read", "lamp:write",
|
||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
"consultation:read", "consultation:write",
|
"consultation:read", "consultation:write",
|
||||||
|
"trace:read", "trace:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -85,6 +89,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"lamp:read",
|
"lamp:read",
|
||||||
"consumable:read",
|
"consumable:read",
|
||||||
"consultation:read",
|
"consultation:read",
|
||||||
|
"trace:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -97,5 +102,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"lamp:read",
|
"lamp:read",
|
||||||
"consumable:read",
|
"consumable:read",
|
||||||
"consultation:read",
|
"consultation:read",
|
||||||
|
"trace:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraceRecord 疫病溯源记录(规格书 3.8:三级溯源)
|
||||||
|
type TraceRecord 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"`
|
||||||
|
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
|
||||||
|
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
|
||||||
|
Disease string `gorm:"size:64" json:"disease"`
|
||||||
|
Status string `gorm:"size:16;default:pending" json:"status"` // pending/reported/analysis/archived
|
||||||
|
Origin *string `gorm:"size:32" json:"origin,omitempty"` // internal/external/unknown
|
||||||
|
Confidence *float64 `gorm:"type:float" json:"confidence,omitempty"`
|
||||||
|
AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报
|
||||||
|
Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单
|
||||||
|
AnalysisReport json.RawMessage `gorm:"column:analysis_report;type:jsonb" json:"analysisReport,omitempty"` // 二级报告
|
||||||
|
ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家
|
||||||
|
LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TraceRecord) TableName() string { return "trace_records" }
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvSample 环境回溯样本
|
||||||
|
type EnvSample struct {
|
||||||
|
Time time.Time
|
||||||
|
Temp *float64
|
||||||
|
Humidity *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvironmentResult 环境回溯结果
|
||||||
|
type EnvironmentResult struct {
|
||||||
|
HighHumidity bool
|
||||||
|
TempSwing bool
|
||||||
|
Summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvironmentBacktrack 发病前环境回溯:湿度≥阈值 或 温度>30/<20 触发(规格书 3.8.3)
|
||||||
|
func EnvironmentBacktrack(samples []EnvSample, humidityThreshold float64) EnvironmentResult {
|
||||||
|
r := EnvironmentResult{}
|
||||||
|
if len(samples) == 0 {
|
||||||
|
r.Summary = "无可用环境遥测数据"
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.Humidity != nil && *s.Humidity >= humidityThreshold {
|
||||||
|
r.HighHumidity = true
|
||||||
|
}
|
||||||
|
if s.Temp != nil && (*s.Temp > 30 || *s.Temp < 20) {
|
||||||
|
r.TempSwing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.HighHumidity && r.TempSwing:
|
||||||
|
r.Summary = "发病前环境存在持续高湿与温度突变,符合真菌病与核型多角体病诱发条件"
|
||||||
|
case r.HighHumidity:
|
||||||
|
r.Summary = fmt.Sprintf("发病前环境湿度 ≥%.0f%%,符合高湿诱病条件", humidityThreshold)
|
||||||
|
case r.TempSwing:
|
||||||
|
r.Summary = "发病前环境温度突变(>30℃ 或 <20℃),易诱发潜伏感染转急性"
|
||||||
|
default:
|
||||||
|
r.Summary = "发病前环境温湿度未见明显异常"
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// PastEvent 历史发病事件
|
||||||
|
type PastEvent struct {
|
||||||
|
Disease string
|
||||||
|
DaysAgo int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryAssociation 历史发病关联:同房间/同病种既往 90 天内有发病 → 连发性
|
||||||
|
func HistoryAssociation(past []PastEvent, disease string) bool {
|
||||||
|
for _, e := range past {
|
||||||
|
if e.Disease == disease && e.DaysAgo <= 90 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransmissionInference 按病种推断传播途径(规格书 3.8.2)
|
||||||
|
func TransmissionInference(disease string) (mode, source string) {
|
||||||
|
switch disease {
|
||||||
|
case "核型多角体病":
|
||||||
|
return "食下传染为主,创伤传染次之", "多角体污染蚕座/桑叶/蚕具,环境残留或新引入"
|
||||||
|
case "白僵病", "其他真菌病(绿僵病/黄僵病/曲霉病)":
|
||||||
|
return "接触传染/气流扩散", "分生孢子经气流带入,或蚕房尸体产孢内源扩散"
|
||||||
|
case "软化病", "浓核病/传染性软化病":
|
||||||
|
return "食下传染", "桑园害虫粪便污染桑叶,或蚕座环境累积污染"
|
||||||
|
case "微粒子病":
|
||||||
|
return "食下传染+胚种传染", "蚕种带毒(垂直传播)或环境孢子水平传播"
|
||||||
|
case "细菌性败血病":
|
||||||
|
return "创伤传染", "蚕具刮伤/互相抓伤后环境细菌侵入"
|
||||||
|
case "猝倒病":
|
||||||
|
return "食下传染(细菌毒素)", "桑叶被细菌污染"
|
||||||
|
default:
|
||||||
|
return "未知", "病种不明,建议先确认病原"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChecklistItem 排查清单项
|
||||||
|
type ChecklistItem struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
InternalBias bool `json:"internalBias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiseaseChecklist 分病种二级排查清单(规格书 3.8.3)
|
||||||
|
func DiseaseChecklist(disease string) []ChecklistItem {
|
||||||
|
switch disease {
|
||||||
|
case "核型多角体病":
|
||||||
|
return []ChecklistItem{
|
||||||
|
{Key: "disinfect", Label: "上批次是否发生过同种病?消毒是否彻底(含蚕具浸泡)?", InternalBias: true},
|
||||||
|
{Key: "tray_share", Label: "蚕具是否跨批次共用?", InternalBias: true},
|
||||||
|
{Key: "mulberry", Label: "桑叶来源是否变更?", InternalBias: false},
|
||||||
|
{Key: "entry", Label: "近期人员进出/蚕具借用记录是否有异常?", InternalBias: false},
|
||||||
|
}
|
||||||
|
case "白僵病":
|
||||||
|
return []ChecklistItem{
|
||||||
|
{Key: "vent", Label: "近期是否连续阴雨且通风不足?", InternalBias: true},
|
||||||
|
{Key: "corpse", Label: "蚕房附近是否堆放蚕沙或病蚕尸体?", InternalBias: true},
|
||||||
|
{Key: "outdoor", Label: "蚕房周边桑园昆虫是否有白僵病死亡个体?", InternalBias: false},
|
||||||
|
{Key: "filter", Label: "通风口是否有过滤防虫措施?", InternalBias: false},
|
||||||
|
}
|
||||||
|
case "微粒子病":
|
||||||
|
return []ChecklistItem{
|
||||||
|
{Key: "seed", Label: "蚕种来源批次与供应商是否可追溯?", InternalBias: true},
|
||||||
|
{Key: "quarantine", Label: "检疫证明与母蛾镜检结果是否齐全?", InternalBias: true},
|
||||||
|
{Key: "wild", Label: "桑园周边野外昆虫是否有微孢子虫?", InternalBias: false},
|
||||||
|
{Key: "cross", Label: "同批次蚕种在其他蚕房是否发病?", InternalBias: false},
|
||||||
|
}
|
||||||
|
case "软化病", "浓核病/传染性软化病":
|
||||||
|
return []ChecklistItem{
|
||||||
|
{Key: "density", Label: "蚕头密度是否过大?", InternalBias: true},
|
||||||
|
{Key: "vent", Label: "通风是否不良?", InternalBias: true},
|
||||||
|
{Key: "mulberry", Label: "桑叶是否潮湿或储运不达标?", InternalBias: true},
|
||||||
|
{Key: "pest", Label: "桑园近期害虫防治记录是否正常?", InternalBias: false},
|
||||||
|
}
|
||||||
|
case "细菌性败血病":
|
||||||
|
return []ChecklistItem{
|
||||||
|
{Key: "tool", Label: "蚕具是否有尖锐边缘/毛刺?", InternalBias: true},
|
||||||
|
{Key: "density", Label: "蚕头密度是否超标导致相互抓伤?", InternalBias: true},
|
||||||
|
{Key: "op", Label: "给桑操作是否粗暴?", InternalBias: true},
|
||||||
|
{Key: "disinfect", Label: "消毒是否有盲区?", InternalBias: true},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChecklistAnswer 清单作答
|
||||||
|
type ChecklistAnswer struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Answer string `json:"answer"` // yes/no/unknown
|
||||||
|
InternalBias bool `json:"internalBias"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeChecklist 按作答统计内源/外源倾向并给出结论
|
||||||
|
func AnalyzeChecklist(answers []ChecklistAnswer) (origin string, confidence float64, conclusion string) {
|
||||||
|
internal, external, unknown := 0, 0, 0
|
||||||
|
for _, a := range answers {
|
||||||
|
switch a.Answer {
|
||||||
|
case "yes":
|
||||||
|
if a.InternalBias {
|
||||||
|
internal++
|
||||||
|
} else {
|
||||||
|
external++
|
||||||
|
}
|
||||||
|
case "no":
|
||||||
|
if a.InternalBias {
|
||||||
|
external++
|
||||||
|
} else {
|
||||||
|
internal++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
unknown++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := internal + external + unknown
|
||||||
|
if total == 0 {
|
||||||
|
return "unknown", 0, "未填写排查清单,无法判定来源"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case internal > external:
|
||||||
|
origin = "internal"
|
||||||
|
confidence = 0.5 + 0.3*float64(internal)/float64(total)
|
||||||
|
conclusion = "倾向内源扩散(环境残留/蚕座污染),建议定向清除环境残留并升级消毒"
|
||||||
|
case external > internal:
|
||||||
|
origin = "external"
|
||||||
|
confidence = 0.5 + 0.3*float64(external)/float64(total)
|
||||||
|
conclusion = "倾向外源侵入(桑叶/气流/种源),建议封堵外部侵入途径"
|
||||||
|
default:
|
||||||
|
origin = "unknown"
|
||||||
|
confidence = 0.5
|
||||||
|
conclusion = "内源与外源线索相当,建议结合专家会诊进一步确认"
|
||||||
|
}
|
||||||
|
return origin, confidence, conclusion
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEnvironmentBacktrack(t *testing.T) {
|
||||||
|
// 高湿 + 温度突变 → 双触发
|
||||||
|
samples := []EnvSample{
|
||||||
|
{Temp: ptrF(28), Humidity: ptrF(85)},
|
||||||
|
{Temp: ptrF(32), Humidity: ptrF(90)},
|
||||||
|
}
|
||||||
|
r := EnvironmentBacktrack(samples, 80)
|
||||||
|
if !r.HighHumidity || !r.TempSwing {
|
||||||
|
t.Errorf("应同时检出高湿与温度突变: %+v", r)
|
||||||
|
}
|
||||||
|
// 正常环境
|
||||||
|
normal := []EnvSample{{Temp: ptrF(25), Humidity: ptrF(60)}}
|
||||||
|
r2 := EnvironmentBacktrack(normal, 80)
|
||||||
|
if r2.HighHumidity || r2.TempSwing || len(r2.Summary) == 0 {
|
||||||
|
t.Errorf("正常环境不应触发: %+v", r2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryAssociation(t *testing.T) {
|
||||||
|
past := []PastEvent{{Disease: "白僵病", DaysAgo: 30}}
|
||||||
|
if !HistoryAssociation(past, "白僵病") {
|
||||||
|
t.Error("同病种历史发病应判连发性")
|
||||||
|
}
|
||||||
|
if HistoryAssociation(past, "软化病") {
|
||||||
|
t.Error("不同病种不应判连发性")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransmissionInference(t *testing.T) {
|
||||||
|
if mode, _ := TransmissionInference("白僵病"); mode != "接触传染/气流扩散" {
|
||||||
|
t.Errorf("白僵病传播方式 = %s", mode)
|
||||||
|
}
|
||||||
|
if mode, _ := TransmissionInference("微粒子病"); mode != "食下传染+胚种传染" {
|
||||||
|
t.Errorf("微粒子病传播方式 = %s", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiseaseChecklist(t *testing.T) {
|
||||||
|
if len(DiseaseChecklist("核型多角体病")) < 3 {
|
||||||
|
t.Error("核型多角体病排查清单应 ≥3 项")
|
||||||
|
}
|
||||||
|
if len(DiseaseChecklist("未知病种")) != 0 {
|
||||||
|
t.Error("未知病种应返回空清单")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeChecklist(t *testing.T) {
|
||||||
|
items := []ChecklistAnswer{
|
||||||
|
{Key: "disinfect", Label: "上批次消毒是否彻底", Answer: "yes", InternalBias: true},
|
||||||
|
{Key: "tray_share", Label: "蚕具是否跨批次共用", Answer: "yes", InternalBias: true},
|
||||||
|
{Key: "mulberry", Label: "桑叶来源是否变更", Answer: "no", InternalBias: false},
|
||||||
|
}
|
||||||
|
origin, conf, conclusion := AnalyzeChecklist(items)
|
||||||
|
if origin != "internal" {
|
||||||
|
t.Errorf("内源倾向答案应判 internal,实际 %s(结论 %s)", origin, conclusion)
|
||||||
|
}
|
||||||
|
if conf <= 0.5 {
|
||||||
|
t.Errorf("置信度应 >0.5,实际 %.2f", conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrF(v float64) *float64 { return &v }
|
||||||
Reference in New Issue
Block a user