diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 390a375..2938cda 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -118,6 +118,7 @@ func main() { handler.RegisterConsumableRoutes(api, db) handler.RegisterConsultationRoutes(api, db) handler.RegisterDetectionMethodRoutes(api, db) + handler.RegisterTraceRoutes(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 e34879e..853f624 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -36,6 +36,7 @@ func Init(cfg *config.Config) error { &model.Consumable{}, &model.Consultation{}, &model.SpectrumEntry{}, + &model.TraceRecord{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/trace.go b/server-go/internal/handler/trace.go new file mode 100644 index 0000000..3d5b4d2 --- /dev/null +++ b/server-go/internal/handler/trace.go @@ -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) + } +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 95f9f65..2da896c 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -41,6 +41,8 @@ var AllPermissions = []PermissionDef{ {"consumable:write", "耗材管理", "新增、编辑、删除耗材"}, {"consultation:read", "会诊查看", "查看专家会诊单与病例快照"}, {"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"}, + {"trace:read", "溯源查看", "查看疫病溯源记录与报告"}, + {"trace:write", "溯源管理", "发起溯源、执行排查清单与报告"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -59,6 +61,7 @@ var RolePermissionMap = map[string][]string{ "lamp:read", "lamp:write", "consumable:read", "consumable:write", "consultation:read", "consultation:write", + "trace:read", "trace:write", "user:manage", "audit:read", }, RoleOperator: { @@ -73,6 +76,7 @@ var RolePermissionMap = map[string][]string{ "lamp:read", "lamp:write", "consumable:read", "consumable:write", "consultation:read", "consultation:write", + "trace:read", "trace:write", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -85,6 +89,7 @@ var RolePermissionMap = map[string][]string{ "lamp:read", "consumable:read", "consultation:read", + "trace:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -97,5 +102,6 @@ var RolePermissionMap = map[string][]string{ "lamp:read", "consumable:read", "consultation:read", + "trace:read", }, } diff --git a/server-go/internal/model/trace_record.go b/server-go/internal/model/trace_record.go new file mode 100644 index 0000000..6f6c7f6 --- /dev/null +++ b/server-go/internal/model/trace_record.go @@ -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" } diff --git a/server-go/internal/service/trace.go b/server-go/internal/service/trace.go new file mode 100644 index 0000000..e7511f3 --- /dev/null +++ b/server-go/internal/service/trace.go @@ -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 +} diff --git a/server-go/internal/service/trace_test.go b/server-go/internal/service/trace_test.go new file mode 100644 index 0000000..7d3bb7f --- /dev/null +++ b/server-go/internal/service/trace_test.go @@ -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 }