From 126c215a6b7d7b2220d55690788cfe198594168e Mon Sep 17 00:00:00 2001 From: weijuesen Date: Fri, 14 Aug 2026 10:33:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E8=A7=84=E5=88=99=E3=80=81=E4=BC=9A=E8=AF=8A=E6=B2=BB=E7=90=86?= =?UTF-8?q?=E3=80=81=E7=9F=A5=E8=AF=86=E5=AE=A1=E6=A0=B8=E4=B8=8E=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E8=AF=84=E4=BC=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + server-go/internal/database/db.go | 11 +- server-go/internal/database/migrate.go | 2 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/consultation.go | 50 +++++- .../internal/handler/consultation_test.go | 28 ++++ server-go/internal/handler/health.go | 54 +++++++ server-go/internal/handler/knowledge.go | 49 ++++++ server-go/internal/model/consultation.go | 76 +++++---- server-go/internal/model/consultation_test.go | 47 ++++-- server-go/internal/model/governance.go | 49 ++++++ server-go/internal/model/knowledge.go | 82 ++++++---- .../model/knowledge_governance_test.go | 23 +++ server-go/internal/service/health_profile.go | 97 +++++++++++ .../internal/service/health_profile_test.go | 43 ++++- server-go/internal/service/rule_engine.go | 150 ++++++++++++++++++ .../internal/service/rule_engine_test.go | 60 +++++++ .../000008_governance_effectiveness.down.sql | 24 +++ .../000008_governance_effectiveness.up.sql | 66 ++++++++ web/src/dal/consultation.ts | 8 +- web/src/dal/health.ts | 14 ++ web/src/dal/knowledge.ts | 8 + web/src/pages/Consultations.tsx | 24 ++- web/src/pages/Knowledge.tsx | 10 ++ web/src/pages/Traces.tsx | 37 +++++ 后续工作计划.md | 4 +- 开发交接记录.md | 30 ++++ 27 files changed, 963 insertions(+), 88 deletions(-) create mode 100644 server-go/internal/handler/consultation_test.go create mode 100644 server-go/internal/model/governance.go create mode 100644 server-go/internal/model/knowledge_governance_test.go create mode 100644 server-go/internal/service/rule_engine.go create mode 100644 server-go/internal/service/rule_engine_test.go create mode 100644 server-go/migrations/000008_governance_effectiveness.down.sql create mode 100644 server-go/migrations/000008_governance_effectiveness.up.sql diff --git a/README.md b/README.md index 04e871b..1caa320 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ - 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案 - Web「专家会诊」页 +- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告 ### 1.13 疫病溯源与区域统计(计划 #21/#22) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index c2f0720..3894b29 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -44,6 +44,9 @@ func Init(cfg *config.Config) error { &model.SeedSource{}, &model.DisinfectionRecord{}, &model.IdentityLink{}, + &model.ConsultationOpinionVersion{}, + &model.RuleEngineResult{}, + &model.KnowledgeReview{}, &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, &model.WeatherAlert{}, @@ -71,7 +74,9 @@ func seedKnowledge(db *gorm.DB) { var cnt int64 db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt) if cnt == 0 { - if err := db.Create(&d).Error; err != nil { + seed := d + seed.Status = "published" + if err := db.Create(&seed).Error; err != nil { slog.Warn("写入病种种子失败", "name", d.Name, "err", err) } } @@ -80,7 +85,9 @@ func seedKnowledge(db *gorm.DB) { var cnt int64 db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt) if cnt == 0 { - if err := db.Create(&a).Error; err != nil { + seed := a + seed.Status = "published" + if err := db.Create(&seed).Error; err != nil { slog.Warn("写入知识文章种子失败", "title", a.Title, "err", err) } } diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index 64f3b1e..e581b86 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "7" +const CurrentSchemaVersion = "8" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index 36060b2..5a83e75 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -120,4 +120,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if err != nil || next != 7 { t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err) } + next, err = driver.Next(next) + if err != nil || next != 8 { + t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err) + } } diff --git a/server-go/internal/handler/consultation.go b/server-go/internal/handler/consultation.go index 83fb19b..2e3e3db 100644 --- a/server-go/internal/handler/consultation.go +++ b/server-go/internal/handler/consultation.go @@ -71,10 +71,10 @@ func getConsultation(db *gorm.DB) gin.HandlerFunc { 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"` + 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 { @@ -93,7 +93,7 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc { BatchID: body.BatchID, LampTestID: body.LampTestID, Summary: body.Summary, - Status: "pending", + Status: "unassigned", } if body.Title != "" { rec.Title = body.Title @@ -183,6 +183,16 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"}) return } + now := time.Now() + switch to { + case "assigned": + updates["assigned_at"] = now + updates["sla_deadline"] = now.Add(24 * time.Hour) + case "accepted": + updates["accepted_at"] = now + case "needs_info": + updates["needs_info_at"] = now + } } if len(updates) > 0 { db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates) @@ -208,6 +218,7 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc { var body struct { Opinion string `json:"opinion"` Plan string `json:"plan"` + Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -218,19 +229,44 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc { return } now := time.Now() + authorID := currentUserID(c) updates := map[string]interface{}{ "status": "resolved", - "expert_id": currentUserID(c), + "expert_id": authorID, "opinion": body.Opinion, "plan": body.Plan, "resolved_at": now, } - db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates) + var versionCount int64 + db.Model(&model.ConsultationOpinionVersion{}).Where("consultation_id = ?", id).Count(&versionCount) + version := int(versionCount) + 1 + if err := db.Create(&model.ConsultationOpinionVersion{ + ConsultationID: id, + Version: version, + AuthorID: authorID, + Opinion: body.Opinion, + Plan: body.Plan, + Reason: consultationStrPtr(body.Reason), + }).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存意见版本失败"}) + return + } + if err := db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存会诊方案失败"}) + return + } db.Where("id = ?", id).First(&t) c.JSON(http.StatusOK, t) } } +func consultationStrPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + // archiveConsultation 归档 func archiveConsultation(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/handler/consultation_test.go b/server-go/internal/handler/consultation_test.go new file mode 100644 index 0000000..ef54c7c --- /dev/null +++ b/server-go/internal/handler/consultation_test.go @@ -0,0 +1,28 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestConsultationStrPtr(t *testing.T) { + if consultationStrPtr("") != nil { + t.Error("空字符串应为 nil") + } + if got := consultationStrPtr("原因"); got == nil || *got != "原因" { + t.Errorf("非空字符串应返回指针: %v", got) + } +} + +func TestCanReviewKnowledge(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + if canReviewKnowledge(c) { + t.Error("nil context 不应通过") + } +} diff --git a/server-go/internal/handler/health.go b/server-go/internal/handler/health.go index 19c0a80..805aa0a 100644 --- a/server-go/internal/handler/health.go +++ b/server-go/internal/handler/health.go @@ -24,6 +24,9 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) { rg.GET("/health-profiles/:roomId", middleware.RequirePermission(db, "room:read"), roomHealthProfile(db)) + rg.GET("/health-profiles/:roomId/effect", + middleware.RequirePermission(db, "room:read"), + roomEffectReport(db)) } // roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分 @@ -99,3 +102,54 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc { }) } } + +// roomEffectReport 处置前后窗口效果报告。 +func roomEffectReport(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + roomID := c.Param("roomId") + now := time.Now() + parse := func(key string, fallback time.Time) time.Time { + raw := c.Query(key) + if raw == "" { + return fallback + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return fallback + } + return t + } + window := service.EffectWindow{ + BeforeStart: parse("beforeStart", now.Add(-60*24*time.Hour)), + BeforeEnd: parse("beforeEnd", now.Add(-30*24*time.Hour)), + AfterStart: parse("afterStart", now.Add(-30*24*time.Hour)), + AfterEnd: parse("afterEnd", now), + } + before, after := service.EffectMetrics{}, service.EffectMetrics{} + loadEffectMetrics := func(start, end time.Time) service.EffectMetrics { + metrics := service.EffectMetrics{} + db.Table("inspection_records"). + Where("room_id = ? AND ai_status = 'done' AND COALESCE(is_mock, false) = false AND risk_score IS NOT NULL AND created_at >= ? AND created_at < ?", roomID, start, end). + Pluck("risk_score", &metrics.RiskSamples) + var lampTotal int64 + db.Model(&model.LampTest{}). + Where("room_id = ? AND status = 'resulted' AND created_at >= ? AND created_at < ?", roomID, start, end). + Count(&lampTotal) + metrics.LampTotal = int(lampTotal) + var lampPositive int64 + db.Model(&model.LampTest{}). + Where("room_id = ? AND status = 'resulted' AND result = 'positive' AND created_at >= ? AND created_at < ?", roomID, start, end). + Count(&lampPositive) + metrics.LampPositive = int(lampPositive) + return metrics + } + before = loadEffectMetrics(window.BeforeStart, window.BeforeEnd) + after = loadEffectMetrics(window.AfterStart, window.AfterEnd) + var recurrences int64 + db.Model(&model.DiseaseEvent{}). + Where("room_id = ? AND status = 'reopened' AND created_at >= ? AND created_at < ?", roomID, window.AfterStart, window.AfterEnd). + Count(&recurrences) + after.Recurrences = int(recurrences) + c.JSON(http.StatusOK, service.EvaluateControlEffect(roomID, window, before, after)) + } +} diff --git a/server-go/internal/handler/knowledge.go b/server-go/internal/handler/knowledge.go index 6226fe2..606099f 100644 --- a/server-go/internal/handler/knowledge.go +++ b/server-go/internal/handler/knowledge.go @@ -80,6 +80,15 @@ func listDiseases(db *gorm.DB) gin.HandlerFunc { if enabled := c.Query("enabled"); enabled != "" { q = q.Where("enabled = ?", enabled == "true") } + if status := c.Query("status"); status != "" { + if !canReviewKnowledge(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"}) + return + } + q = q.Where("status = ?", status) + } else { + q = q.Where("status = 'published'") + } var list []model.Disease q.Order("sort_order ASC, name ASC").Find(&list) c.JSON(http.StatusOK, list) @@ -94,6 +103,10 @@ func getDisease(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"}) return } + if d.Status != "published" && !canReviewKnowledge(c) { + c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"}) + return + } c.JSON(http.StatusOK, d) } } @@ -107,6 +120,7 @@ func createDisease(db *gorm.DB) gin.HandlerFunc { return } d.ID = "" + d.Status = "draft" if d.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"}) return @@ -134,6 +148,10 @@ func updateDisease(db *gorm.DB) gin.HandlerFunc { return } if len(updates) > 0 { + if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(d.Status, status) { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"}) + return + } db.Model(&model.Disease{}).Where("id = ?", id).Updates(updates) } db.Where("id = ?", id).First(&d) @@ -165,6 +183,15 @@ func listArticles(db *gorm.DB) gin.HandlerFunc { if enabled := c.Query("enabled"); enabled != "" { q = q.Where("enabled = ?", enabled == "true") } + if status := c.Query("status"); status != "" { + if !canReviewKnowledge(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"}) + return + } + q = q.Where("status = ?", status) + } else { + q = q.Where("status = 'published'") + } var list []model.KnowledgeArticle q.Order("sort_order ASC, created_at DESC").Find(&list) c.JSON(http.StatusOK, list) @@ -179,6 +206,10 @@ func getArticle(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "article not found"}) return } + if a.Status != "published" && !canReviewKnowledge(c) { + c.JSON(http.StatusNotFound, gin.H{"error": "article not found"}) + return + } c.JSON(http.StatusOK, a) } } @@ -192,6 +223,7 @@ func createArticle(db *gorm.DB) gin.HandlerFunc { return } a.ID = "" + a.Status = "draft" if a.Kind == "" || a.Title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"}) return @@ -219,6 +251,10 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc { return } if len(updates) > 0 { + if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(a.Status, status) { + c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"}) + return + } db.Model(&model.KnowledgeArticle{}).Where("id = ?", id).Updates(updates) } db.Where("id = ?", id).First(&a) @@ -226,6 +262,19 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc { } } +func canReviewKnowledge(c *gin.Context) bool { + user, ok := c.Get("user") + if !ok { + return false + } + m, ok := user.(map[string]interface{}) + if !ok { + return false + } + role, _ := m["role"].(string) + return role == "admin" || role == "operator" +} + // deleteArticle 删除知识文章 func deleteArticle(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/model/consultation.go b/server-go/internal/model/consultation.go index f0c62b6..0fba4d3 100644 --- a/server-go/internal/model/consultation.go +++ b/server-go/internal/model/consultation.go @@ -7,46 +7,68 @@ import ( // 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"` + 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"` + AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"` + AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"` + AcceptedAt *time.Time `gorm:"column:accepted_at;type:timestamptz" json:"acceptedAt,omitempty"` + NeedsInfoAt *time.Time `gorm:"column:needs_info_at;type:timestamptz" json:"needsInfoAt,omitempty"` + SLADeadline *time.Time `gorm:"column:sla_deadline;type:timestamptz" json:"slaDeadline,omitempty"` + OverdueAt *time.Time `gorm:"column:overdue_at;type:timestamptz" json:"overdueAt,omitempty"` + LastSLAEventAt *time.Time `gorm:"column:last_sla_event_at;type:timestamptz" json:"lastSlaEventAt,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 +// ValidConsultationTransition 状态流转规则:unassigned/assigned/accepted/needs_info/resolved/archived。 func ValidConsultationTransition(from, to string) bool { switch from { - case "pending": - return to == "consulting" || to == "resolved" - case "consulting": - return to == "resolved" + case "unassigned": + return to == "assigned" + case "assigned": + return to == "accepted" || to == "needs_info" + case "accepted": + return to == "needs_info" || to == "resolved" + case "needs_info": + return to == "accepted" || to == "resolved" case "resolved": - return to == "archived" + return to == "archived" || to == "needs_info" default: return false } } +// ConsultationSLAState 返回会诊 SLA 状态;超时只标记,不自动伪造专家结论。 +func ConsultationSLAState(t Consultation, now time.Time) string { + if t.SLADeadline == nil { + return "unscheduled" + } + if now.After(*t.SLADeadline) { + return "overdue" + } + return "on_time" +} + // ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化) type ConsultationSnapshot struct { - RoomName string `json:"roomName,omitempty"` - LampTest *LampTest `json:"lampTest,omitempty"` + 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"` + 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 index c54a8da..599a20a 100644 --- a/server-go/internal/model/consultation_test.go +++ b/server-go/internal/model/consultation_test.go @@ -8,9 +8,11 @@ import ( func TestValidConsultationTransition(t *testing.T) { ok := [][2]string{ - {"pending", "consulting"}, - {"pending", "resolved"}, - {"consulting", "resolved"}, + {"unassigned", "assigned"}, + {"assigned", "accepted"}, + {"assigned", "needs_info"}, + {"accepted", "resolved"}, + {"needs_info", "resolved"}, {"resolved", "archived"}, } for _, c := range ok { @@ -19,7 +21,8 @@ func TestValidConsultationTransition(t *testing.T) { } } bad := [][2]string{ - {"pending", "archived"}, + {"unassigned", "archived"}, + {"unassigned", "resolved"}, {"resolved", "resolved"}, {"archived", "pending"}, {"", "resolved"}, @@ -31,15 +34,41 @@ func TestValidConsultationTransition(t *testing.T) { } } +func TestConsultationRejectsArchiveBeforeResolution(t *testing.T) { + if ValidConsultationTransition("accepted", "archived") { + t.Fatal("accepted 不能直接归档") + } + if ValidConsultationTransition("unassigned", "archived") { + t.Fatal("unassigned 不能直接归档") + } +} + +func TestConsultationSLAMarksOverdue(t *testing.T) { + now := time.Now() + deadline := now.Add(-time.Hour) + task := Consultation{Status: "assigned", SLADeadline: &deadline} + if state := ConsultationSLAState(task, now); state != "overdue" { + t.Fatalf("SLA 状态 = %s, want overdue", state) + } + future := now.Add(time.Hour) + task.SLADeadline = &future + if state := ConsultationSLAState(task, now); state != "on_time" { + t.Fatalf("SLA 状态 = %s, want on_time", state) + } + if state := ConsultationSLAState(Consultation{}, now); state != "unscheduled" { + t.Fatalf("SLA 状态 = %s, want unscheduled", state) + } +} + 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"}, + 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, + CreatedAt: now, } raw, err := json.Marshal(s) if err != nil { diff --git a/server-go/internal/model/governance.go b/server-go/internal/model/governance.go new file mode 100644 index 0000000..3add6b1 --- /dev/null +++ b/server-go/internal/model/governance.go @@ -0,0 +1,49 @@ +package model + +import ( + "encoding/json" + "time" +) + +// ConsultationOpinionVersion 专家意见版本,保留作者、时间、旧版本和修改原因。 +type ConsultationOpinionVersion struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + ConsultationID string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId"` + Version int `json:"version"` + AuthorID *string `gorm:"column:author_id;type:uuid" json:"authorId,omitempty"` + Opinion string `gorm:"type:text" json:"opinion"` + Plan string `gorm:"type:text" json:"plan"` + Reason *string `gorm:"type:text" json:"reason,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` +} + +func (ConsultationOpinionVersion) TableName() string { return "consultation_opinion_versions" } + +// RuleEngineResult 规则执行结果持久化。 +type RuleEngineResult struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + RuleVersion string `gorm:"column:rule_version;size:64" json:"ruleVersion"` + Disease string `gorm:"size:64" json:"disease"` + Level *string `gorm:"size:16" json:"level,omitempty"` + Reason *string `gorm:"type:text" json:"reason,omitempty"` + InputSnapshot json.RawMessage `gorm:"column:input_snapshot;type:jsonb" json:"inputSnapshot"` + Missing json.RawMessage `gorm:"type:jsonb" json:"missing"` + ComputedAt time.Time `gorm:"column:computed_at;type:timestamptz" json:"computedAt"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` +} + +func (RuleEngineResult) TableName() string { return "rule_engine_results" } + +// KnowledgeReview 知识内容审核记录。 +type KnowledgeReview struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + EntityType string `gorm:"column:entity_type;size:32;index" json:"entityType"` + EntityID string `gorm:"column:entity_id;size:128;index" json:"entityId"` + Status string `gorm:"size:16" json:"status"` + ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"` + ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` +} + +func (KnowledgeReview) TableName() string { return "knowledge_reviews" } diff --git a/server-go/internal/model/knowledge.go b/server-go/internal/model/knowledge.go index 7dd259c..4695b47 100644 --- a/server-go/internal/model/knowledge.go +++ b/server-go/internal/model/knowledge.go @@ -4,41 +4,65 @@ import "time" // Disease 蚕病百科条目(知识库-蚕病百科) type Disease struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"size:64" json:"name"` - Category string `gorm:"size:32;index" json:"category"` // viral/fungal/bacterial/protozoan/other - Pathogen string `gorm:"type:text" json:"pathogen"` - Transmission string `gorm:"type:text" json:"transmission"` - Medium string `gorm:"type:text" json:"medium"` - Incubation string `gorm:"type:text" json:"incubation"` - Symptoms string `gorm:"type:text" json:"symptoms"` - HighRiskStage string `gorm:"column:high_risk_stage;type:text" json:"highRiskStage"` - HighRiskCondition string `gorm:"column:high_risk_condition;type:text" json:"highRiskCondition"` - LethalTime string `gorm:"column:lethal_time;type:text" json:"lethalTime"` - SpreadTrend string `gorm:"column:spread_trend;type:text" json:"spreadTrend"` - Recurrence string `gorm:"type:text" json:"recurrence"` - Detection string `gorm:"type:text" json:"detection"` - Prevention string `gorm:"type:text" json:"prevention"` - ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"` - SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` - UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"size:64" json:"name"` + Category string `gorm:"size:32;index" json:"category"` // viral/fungal/bacterial/protozoan/other + Pathogen string `gorm:"type:text" json:"pathogen"` + Transmission string `gorm:"type:text" json:"transmission"` + Medium string `gorm:"type:text" json:"medium"` + Incubation string `gorm:"type:text" json:"incubation"` + Symptoms string `gorm:"type:text" json:"symptoms"` + HighRiskStage string `gorm:"column:high_risk_stage;type:text" json:"highRiskStage"` + HighRiskCondition string `gorm:"column:high_risk_condition;type:text" json:"highRiskCondition"` + LethalTime string `gorm:"column:lethal_time;type:text" json:"lethalTime"` + SpreadTrend string `gorm:"column:spread_trend;type:text" json:"spreadTrend"` + Recurrence string `gorm:"type:text" json:"recurrence"` + Detection string `gorm:"type:text" json:"detection"` + Prevention string `gorm:"type:text" json:"prevention"` + ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"` + Status string `gorm:"size:16;default:published;index" json:"status"` + Source *string `gorm:"type:text" json:"source,omitempty"` + SourceDate *time.Time `gorm:"column:source_date;type:date" json:"sourceDate,omitempty"` + ExpertConfirmedBy *string `gorm:"column:expert_confirmed_by;type:uuid" json:"expertConfirmedBy,omitempty"` + SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` } func (Disease) TableName() string { return "diseases" } // KnowledgeArticle 知识文章(AI 结果解读、LAMP/SERS 教程、季节性防控提醒等) type KnowledgeArticle struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Kind string `gorm:"size:32;index" json:"kind"` // ai_guide/lamp_guide/sers_guide/seasonal_tip - Title string `gorm:"size:128" json:"title"` - Summary string `gorm:"type:text" json:"summary"` - Content string `gorm:"type:text" json:"content"` - SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"` - Enabled bool `gorm:"default:true" json:"enabled"` - CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` - UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Kind string `gorm:"size:32;index" json:"kind"` // ai_guide/lamp_guide/sers_guide/seasonal_tip + Title string `gorm:"size:128" json:"title"` + Summary string `gorm:"type:text" json:"summary"` + Content string `gorm:"type:text" json:"content"` + Status string `gorm:"size:16;default:published;index" json:"status"` + Source *string `gorm:"type:text" json:"source,omitempty"` + SourceDate *time.Time `gorm:"column:source_date;type:date" json:"sourceDate,omitempty"` + ExpertConfirmedBy *string `gorm:"column:expert_confirmed_by;type:uuid" json:"expertConfirmedBy,omitempty"` + SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` } func (KnowledgeArticle) TableName() string { return "knowledge_articles" } + +// ValidKnowledgeTransition 知识审核状态机:draft/review/published/withdrawn。 +func ValidKnowledgeTransition(from, to string) bool { + switch from { + case "draft": + return to == "review" || to == "withdrawn" + case "review": + return to == "published" || to == "withdrawn" || to == "draft" + case "published": + return to == "withdrawn" + case "withdrawn": + return to == "review" || to == "draft" + default: + return false + } +} diff --git a/server-go/internal/model/knowledge_governance_test.go b/server-go/internal/model/knowledge_governance_test.go new file mode 100644 index 0000000..555ad09 --- /dev/null +++ b/server-go/internal/model/knowledge_governance_test.go @@ -0,0 +1,23 @@ +package model + +import "testing" + +func TestValidKnowledgeTransition(t *testing.T) { + ok := [][2]string{ + {"draft", "review"}, + {"review", "published"}, + {"published", "withdrawn"}, + {"withdrawn", "review"}, + } + for _, tr := range ok { + if !ValidKnowledgeTransition(tr[0], tr[1]) { + t.Errorf("expected %s -> %s", tr[0], tr[1]) + } + } + if ValidKnowledgeTransition("draft", "published") { + t.Error("draft 不能直接 published") + } + if ValidKnowledgeTransition("published", "draft") { + t.Error("published 不能回草稿") + } +} diff --git a/server-go/internal/service/health_profile.go b/server-go/internal/service/health_profile.go index 81fa8d6..f9739ea 100644 --- a/server-go/internal/service/health_profile.go +++ b/server-go/internal/service/health_profile.go @@ -3,6 +3,7 @@ package service import ( "sort" "strings" + "time" ) // HealthInput 健康画像输入 @@ -85,3 +86,99 @@ func AggregateMonthlyStats(entries []MonthDiseaseEntry) []MonthStat { sort.Slice(result, func(i, j int) bool { return result[i].Month < result[j].Month }) return result } + +// EffectWindow 处置前后评估窗口。 +type EffectWindow struct { + BeforeStart time.Time `json:"beforeStart"` + BeforeEnd time.Time `json:"beforeEnd"` + AfterStart time.Time `json:"afterStart"` + AfterEnd time.Time `json:"afterEnd"` +} + +// EffectMetrics 处置前后指标输入。 +type EffectMetrics struct { + RiskSamples []float64 `json:"riskSamples"` + LampPositive int `json:"lampPositive"` + LampTotal int `json:"lampTotal"` + Recurrences int `json:"recurrences"` + Loss *float64 `json:"loss,omitempty"` + Cost *float64 `json:"cost,omitempty"` +} + +// EffectReport 防控效果报告;缺失维度不按 0 当改善。 +type EffectReport struct { + EventID string `json:"eventId"` + Window EffectWindow `json:"window"` + BeforeRiskAvg *float64 `json:"beforeRiskAvg,omitempty"` + AfterRiskAvg *float64 `json:"afterRiskAvg,omitempty"` + BeforePositiveRate *float64 `json:"beforePositiveRate,omitempty"` + AfterPositiveRate *float64 `json:"afterPositiveRate,omitempty"` + Recurrences int `json:"recurrences"` + Missing []string `json:"missing"` + Conclusion string `json:"conclusion"` +} + +// EvaluateControlEffect 对比指定窗口风险、阳性率和复发;缺失维度单独列出。 +func EvaluateControlEffect(eventID string, window EffectWindow, before, after EffectMetrics) EffectReport { + report := EffectReport{ + EventID: eventID, + Window: window, + Recurrences: after.Recurrences, + Missing: []string{}, + } + if len(before.RiskSamples) > 0 { + value := avg(before.RiskSamples) + report.BeforeRiskAvg = &value + } else { + report.Missing = append(report.Missing, "before_risk") + } + if len(after.RiskSamples) > 0 { + value := avg(after.RiskSamples) + report.AfterRiskAvg = &value + } else { + report.Missing = append(report.Missing, "after_risk") + } + if before.LampTotal > 0 { + value := float64(before.LampPositive) / float64(before.LampTotal) + report.BeforePositiveRate = &value + } else { + report.Missing = append(report.Missing, "before_lamp") + } + if after.LampTotal > 0 { + value := float64(after.LampPositive) / float64(after.LampTotal) + report.AfterPositiveRate = &value + } else { + report.Missing = append(report.Missing, "after_lamp") + } + if before.Loss == nil { + report.Missing = append(report.Missing, "before_loss") + } + if after.Loss == nil { + report.Missing = append(report.Missing, "after_loss") + } + if before.Cost == nil { + report.Missing = append(report.Missing, "before_cost") + } + if after.Cost == nil { + report.Missing = append(report.Missing, "after_cost") + } + switch { + case report.AfterRiskAvg == nil || report.BeforeRiskAvg == nil: + report.Conclusion = "证据不足,无法判断效果" + case *report.AfterRiskAvg < *report.BeforeRiskAvg && after.Recurrences == 0: + report.Conclusion = "改善" + case *report.AfterRiskAvg >= *report.BeforeRiskAvg: + report.Conclusion = "未改善" + default: + report.Conclusion = "需人工复核" + } + return report +} + +func avg(values []float64) float64 { + sum := 0.0 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) +} diff --git a/server-go/internal/service/health_profile_test.go b/server-go/internal/service/health_profile_test.go index 8588adf..9b61c8a 100644 --- a/server-go/internal/service/health_profile_test.go +++ b/server-go/internal/service/health_profile_test.go @@ -1,17 +1,20 @@ package service -import "testing" +import ( + "testing" + "time" +) func TestComputeHealthScore(t *testing.T) { if s, g := ComputeHealthScore(HealthInput{}); s != 100 || g != "优" { t.Errorf("空输入应 100/优,实际 %.1f/%s", s, g) } bad := HealthInput{ - RecentRiskLevels: []string{"red", "red"}, - LampPositive: 1, - LampTotal: 2, + RecentRiskLevels: []string{"red", "red"}, + LampPositive: 1, + LampTotal: 2, ConsultationCount: 1, - TraceCount: 1, + TraceCount: 1, } s, g := ComputeHealthScore(bad) if s >= 80 { @@ -50,3 +53,33 @@ func TestAggregateMonthlyStats(t *testing.T) { t.Error("空输入应返回空") } } + +func TestEvaluateControlEffectComparesWindows(t *testing.T) { + window := EffectWindow{ + BeforeStart: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), + BeforeEnd: time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC), + AfterStart: time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC), + AfterEnd: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC), + } + report := EvaluateControlEffect("event-1", window, EffectMetrics{ + RiskSamples: []float64{80}, LampPositive: 2, LampTotal: 3, + }, EffectMetrics{ + RiskSamples: []float64{40}, LampPositive: 1, LampTotal: 4, + }) + if report.Conclusion != "改善" { + t.Fatalf("结论 = %s, want 改善", report.Conclusion) + } + if report.BeforeRiskAvg == nil || *report.BeforeRiskAvg != 80 { + t.Fatalf("before risk = %+v", report.BeforeRiskAvg) + } +} + +func TestEvaluateControlEffectReportsMissing(t *testing.T) { + report := EvaluateControlEffect("event-1", EffectWindow{}, EffectMetrics{}, EffectMetrics{}) + if report.Conclusion != "证据不足,无法判断效果" { + t.Fatalf("无数据结论应为证据不足,实际 %s", report.Conclusion) + } + if len(report.Missing) < 4 { + t.Fatalf("缺失维度应单独列出: %v", report.Missing) + } +} diff --git a/server-go/internal/service/rule_engine.go b/server-go/internal/service/rule_engine.go new file mode 100644 index 0000000..7171f97 --- /dev/null +++ b/server-go/internal/service/rule_engine.go @@ -0,0 +1,150 @@ +package service + +import ( + "encoding/json" + "fmt" + "sort" + "time" +) + +// TelemetrySample 规则引擎遥测样本。 +type TelemetrySample struct { + Metric string `json:"metric"` + Value float64 `json:"value"` + Timestamp time.Time `json:"timestamp"` +} + +// RuleContext 规则输入;缺失项用 nil 表示,不填 0。 +type RuleContext struct { + Stage string `json:"stage,omitempty"` + Now time.Time `json:"now"` + Samples []TelemetrySample `json:"samples"` + Density *float64 `json:"density,omitempty"` + Ventilation *bool `json:"ventilation,omitempty"` + SeedSourceCount *int `json:"seedSourceCount,omitempty"` + DisinfectionCount *int `json:"disinfectionCount,omitempty"` +} + +// RuleVersion 可版本化规则。 +type RuleVersion struct { + Version string `json:"version"` + Name string `json:"name"` + Disease string `json:"disease"` + WindowHours int `json:"windowHours"` + MaxStaleness time.Duration `json:"-"` + RequiresDensity bool `json:"-"` + RequiresBiosecurity bool `json:"-"` +} + +// RuleResult 规则输出,保留版本、输入快照、缺失项和计算时间。 +type RuleResult struct { + RuleVersion string `json:"ruleVersion"` + RuleName string `json:"ruleName"` + Disease string `json:"disease"` + Matched bool `json:"matched"` + Level string `json:"level,omitempty"` + Reason string `json:"reason,omitempty"` + Missing []string `json:"missing"` + InputSnapshot json.RawMessage `json:"inputSnapshot"` + ComputedAt time.Time `json:"computedAt"` +} + +// EvaluateRules 按版本执行规则,所有缺失输入进入 Missing。 +func EvaluateRules(ctx RuleContext, rules []RuleVersion) []RuleResult { + if ctx.Now.IsZero() { + ctx.Now = time.Now() + } + results := make([]RuleResult, 0, len(rules)) + input, _ := json.Marshal(ctx) + for _, rule := range rules { + missing := ruleMissing(ctx, rule) + result := RuleResult{ + RuleVersion: rule.Version, + RuleName: rule.Name, + Disease: rule.Disease, + Missing: missing, + InputSnapshot: input, + ComputedAt: ctx.Now, + } + if len(missing) == 0 { + switch rule.Name { + case "continuous_humidity": + result.Matched, result.Level, result.Reason = evaluateContinuousHumidity(ctx, rule) + default: + result.Matched = false + result.Reason = "未配置规则实现" + } + } + results = append(results, result) + } + return results +} + +func ruleMissing(ctx RuleContext, rule RuleVersion) []string { + missing := []string{} + hasHumidity := false + hasStaleHumidity := false + for _, sample := range ctx.Samples { + if sample.Metric != "humidity" { + continue + } + if ctx.Now.Sub(sample.Timestamp) > rule.MaxStaleness { + hasStaleHumidity = true + continue + } + hasHumidity = true + } + if rule.Name == "continuous_humidity" { + if hasStaleHumidity && !hasHumidity { + missing = append(missing, "humidity_stale") + } + if !hasHumidity { + missing = append(missing, "humidity") + } + } + if rule.RequiresDensity && ctx.Density == nil { + missing = append(missing, "density") + } + if rule.RequiresBiosecurity { + if ctx.SeedSourceCount == nil { + missing = append(missing, "seed_source") + } + if ctx.DisinfectionCount == nil { + missing = append(missing, "disinfection") + } + } + return missing +} + +func evaluateContinuousHumidity(ctx RuleContext, rule RuleVersion) (bool, string, string) { + byDay := map[string]float64{} + for _, sample := range ctx.Samples { + if sample.Metric != "humidity" { + continue + } + day := sample.Timestamp.Format("2006-01-02") + if sample.Value > byDay[day] { + byDay[day] = sample.Value + } + } + days := make([]string, 0, len(byDay)) + for day := range byDay { + days = append(days, day) + } + sort.Strings(days) + for i := 2; i < len(days); i++ { + if consecutiveDays(days[i-2], days[i-1], days[i]) && byDay[days[i-2]] >= 80 && byDay[days[i-1]] >= 80 && byDay[days[i]] >= 80 { + return true, "orange", fmt.Sprintf("连续 %d 天湿度≥80%%,满足规则 %s", rule.WindowHours, rule.Version) + } + } + return false, "", "连续高湿天数不足或窗口内存在缺失" +} + +func consecutiveDays(a, b, c string) bool { + parse := func(s string) time.Time { + t, _ := time.Parse("2006-01-02", s) + return t + } + da, db, dc := parse(a), parse(b), parse(c) + return db.Sub(da) == 24*time.Hour && dc.Sub(db) == 24*time.Hour +} diff --git a/server-go/internal/service/rule_engine_test.go b/server-go/internal/service/rule_engine_test.go new file mode 100644 index 0000000..3ef1fc1 --- /dev/null +++ b/server-go/internal/service/rule_engine_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "encoding/json" + "testing" + "time" +) + +func TestRuleRequiresThreeContinuousDaysOfHumidity(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + samples := []TelemetrySample{ + {Metric: "humidity", Value: 85, Timestamp: now.Add(-72 * time.Hour)}, + {Metric: "humidity", Value: 83, Timestamp: now.Add(-48 * time.Hour)}, + {Metric: "humidity", Value: 82, Timestamp: now.Add(-24 * time.Hour)}, + } + results := EvaluateRules(RuleContext{Now: now, Samples: samples}, []RuleVersion{{ + Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", WindowHours: 3, MaxStaleness: 100 * time.Hour, + }}) + if len(results) != 1 || !results[0].Matched { + t.Fatalf("连续 3 天高湿应命中: %+v", results) + } +} + +func TestStaleTelemetryIsReportedMissing(t *testing.T) { + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + results := EvaluateRules(RuleContext{Now: now, Samples: []TelemetrySample{{ + Metric: "humidity", Value: 90, Timestamp: now.Add(-24 * time.Hour), + }}}, []RuleVersion{{ + Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", MaxStaleness: 2 * time.Hour, + }}) + if len(results) != 1 || results[0].Matched { + t.Fatalf("过期数据不应命中: %+v", results) + } + found := false + for _, key := range results[0].Missing { + if key == "humidity_stale" { + found = true + } + } + if !found { + t.Fatalf("应标记 humidity_stale: %+v", results[0].Missing) + } +} + +func TestRuleResultKeepsInputAndVersion(t *testing.T) { + now := time.Now() + results := EvaluateRules(RuleContext{Now: now}, []RuleVersion{{ + Version: "env-v1", Name: "missing_rule", Disease: "测试", RequiresBiosecurity: true, + }}) + if results[0].RuleVersion != "env-v1" || results[0].ComputedAt.IsZero() { + t.Fatalf("规则结果缺少版本/计算时间: %+v", results[0]) + } + var snapshot map[string]any + if err := json.Unmarshal(results[0].InputSnapshot, &snapshot); err != nil { + t.Fatalf("inputSnapshot 非法: %v", err) + } + if len(results[0].Missing) == 0 { + t.Fatal("缺失输入应进入 missing") + } +} diff --git a/server-go/migrations/000008_governance_effectiveness.down.sql b/server-go/migrations/000008_governance_effectiveness.down.sql new file mode 100644 index 0000000..973e157 --- /dev/null +++ b/server-go/migrations/000008_governance_effectiveness.down.sql @@ -0,0 +1,24 @@ +ALTER TABLE diseases + DROP COLUMN IF EXISTS expert_confirmed_by, + DROP COLUMN IF EXISTS source_date, + DROP COLUMN IF EXISTS source, + DROP COLUMN IF EXISTS status; + +ALTER TABLE knowledge_articles + DROP COLUMN IF EXISTS expert_confirmed_by, + DROP COLUMN IF EXISTS source_date, + DROP COLUMN IF EXISTS source, + DROP COLUMN IF EXISTS status; + +ALTER TABLE consultations + DROP COLUMN IF EXISTS last_sla_event_at, + DROP COLUMN IF EXISTS overdue_at, + DROP COLUMN IF EXISTS sla_deadline, + DROP COLUMN IF EXISTS needs_info_at, + DROP COLUMN IF EXISTS accepted_at, + DROP COLUMN IF EXISTS assigned_at, + DROP COLUMN IF EXISTS assignee_id; + +DROP TABLE IF EXISTS knowledge_reviews CASCADE; +DROP TABLE IF EXISTS rule_engine_results CASCADE; +DROP TABLE IF EXISTS consultation_opinion_versions CASCADE; diff --git a/server-go/migrations/000008_governance_effectiveness.up.sql b/server-go/migrations/000008_governance_effectiveness.up.sql new file mode 100644 index 0000000..aa39dfb --- /dev/null +++ b/server-go/migrations/000008_governance_effectiveness.up.sql @@ -0,0 +1,66 @@ +CREATE TABLE IF NOT EXISTS consultation_opinion_versions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + consultation_id uuid NOT NULL, + version integer NOT NULL, + author_id uuid, + opinion text NOT NULL, + plan text NOT NULL, + reason text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_consultation_opinion_versions_consultation + ON consultation_opinion_versions (consultation_id, version); + +CREATE TABLE IF NOT EXISTS rule_engine_results ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + rule_version varchar(64) NOT NULL, + disease varchar(64) NOT NULL, + level varchar(16), + reason text, + input_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + missing jsonb NOT NULL DEFAULT '[]'::jsonb, + computed_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_rule_engine_results_rule_version + ON rule_engine_results (rule_version, computed_at); + +CREATE TABLE IF NOT EXISTS knowledge_reviews ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type varchar(32) NOT NULL, + entity_id varchar(128) NOT NULL, + status varchar(16) NOT NULL, + reviewer_id uuid, + reviewed_at timestamptz, + note text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_reviews_entity + ON knowledge_reviews (entity_type, entity_id); + +ALTER TABLE consultations + ADD COLUMN IF NOT EXISTS assignee_id uuid, + ADD COLUMN IF NOT EXISTS assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS accepted_at timestamptz, + ADD COLUMN IF NOT EXISTS needs_info_at timestamptz, + ADD COLUMN IF NOT EXISTS sla_deadline timestamptz, + ADD COLUMN IF NOT EXISTS overdue_at timestamptz, + ADD COLUMN IF NOT EXISTS last_sla_event_at timestamptz; + +UPDATE consultations SET status = 'unassigned' WHERE status = 'pending'; +UPDATE consultations SET status = 'assigned' WHERE status = 'consulting'; + +ALTER TABLE knowledge_articles + ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'published', + ADD COLUMN IF NOT EXISTS source text, + ADD COLUMN IF NOT EXISTS source_date date, + ADD COLUMN IF NOT EXISTS expert_confirmed_by uuid; + +ALTER TABLE diseases + ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'published', + ADD COLUMN IF NOT EXISTS source text, + ADD COLUMN IF NOT EXISTS source_date date, + ADD COLUMN IF NOT EXISTS expert_confirmed_by uuid; diff --git a/web/src/dal/consultation.ts b/web/src/dal/consultation.ts index 30c752d..f43a877 100644 --- a/web/src/dal/consultation.ts +++ b/web/src/dal/consultation.ts @@ -11,6 +11,12 @@ export interface Consultation { snapshot?: any; status: string; expertId?: string; + assigneeId?: string; + assignedAt?: string; + acceptedAt?: string; + needsInfoAt?: string; + slaDeadline?: string; + overdueAt?: string; opinion?: string; plan?: string; resolvedAt?: string; @@ -25,6 +31,6 @@ export const createConsultation = (data: Partial) => post('/consultations', data); export const updateConsultation = (id: string, data: Partial) => patch(`/consultations/${id}`, data); -export const resolveConsultation = (id: string, data: { opinion: string; plan: string }) => +export const resolveConsultation = (id: string, data: { opinion: string; plan: string; reason?: string }) => post(`/consultations/${id}/resolve`, data); export const archiveConsultation = (id: string) => post(`/consultations/${id}/archive`); diff --git a/web/src/dal/health.ts b/web/src/dal/health.ts index 79898a7..f1ebce9 100644 --- a/web/src/dal/health.ts +++ b/web/src/dal/health.ts @@ -11,9 +11,23 @@ export interface HealthProfile { batch?: any; } +export interface EffectReport { + eventId: string; + beforeRiskAvg?: number; + afterRiskAvg?: number; + beforePositiveRate?: number; + afterPositiveRate?: number; + recurrences: number; + missing: string[]; + conclusion: string; +} + export const getHealthProfile = (roomId: string) => get(`/health-profiles/${roomId}`); +export const getRoomEffect = (roomId: string, params?: any) => + get(`/health-profiles/${roomId}/effect`, { params }); + export const getMonthlyStats = (year?: number) => get<{ year: number; stats: { month: string; total: number; diseases: Record }[] }>( '/trace-records/monthly-stats', diff --git a/web/src/dal/knowledge.ts b/web/src/dal/knowledge.ts index f690d5a..da92b96 100644 --- a/web/src/dal/knowledge.ts +++ b/web/src/dal/knowledge.ts @@ -17,6 +17,10 @@ export interface Disease { detection?: string; prevention?: string; imageUrl?: string; + status?: string; + source?: string; + sourceDate?: string; + expertConfirmedBy?: string; sortOrder?: number; enabled?: boolean; createdAt?: string; @@ -29,6 +33,10 @@ export interface KnowledgeArticle { title: string; summary?: string; content?: string; + status?: string; + source?: string; + sourceDate?: string; + expertConfirmedBy?: string; sortOrder?: number; enabled?: boolean; createdAt?: string; diff --git a/web/src/pages/Consultations.tsx b/web/src/pages/Consultations.tsx index 74352e0..4ce98f4 100644 --- a/web/src/pages/Consultations.tsx +++ b/web/src/pages/Consultations.tsx @@ -14,8 +14,10 @@ import { import { authService } from '../services/auth'; const STATUS_LABELS: Record = { - pending: { color: 'gold', text: '待会诊' }, - consulting: { color: 'blue', text: '会诊中' }, + unassigned: { color: 'gold', text: '待分派' }, + assigned: { color: 'blue', text: '已分派' }, + accepted: { color: 'cyan', text: '已受理' }, + needs_info: { color: 'purple', text: '待补充' }, resolved: { color: 'green', text: '已出方案' }, archived: { color: 'default', text: '已归档' }, }; @@ -53,11 +55,23 @@ export default function ConsultationsPage() { , ...(canWrite() ? [ - r.status === 'pending' ? ( + r.status === 'unassigned' ? ( { - await updateConsultation(r.id, { status: 'consulting' }); + await updateConsultation(r.id, { status: 'assigned' }); + message.success('已受理'); + actionRef.current?.reload(); + }} + > + 分派给我 + + ) : null, + r.status === 'assigned' ? ( + { + await updateConsultation(r.id, { status: 'accepted' }); message.success('已受理'); actionRef.current?.reload(); }} @@ -65,7 +79,7 @@ export default function ConsultationsPage() { 受理 ) : null, - r.status === 'pending' || r.status === 'consulting' ? ( + r.status === 'accepted' || r.status === 'needs_info' || r.status === 'assigned' ? ( { diff --git a/web/src/pages/Knowledge.tsx b/web/src/pages/Knowledge.tsx index 16543da..8bad5aa 100644 --- a/web/src/pages/Knowledge.tsx +++ b/web/src/pages/Knowledge.tsx @@ -33,6 +33,13 @@ const KIND_LABELS: Record = { seasonal_tip: '季节性提醒', }; +const STATUS_LABELS: Record = { + draft: { color: 'gold', text: '草稿' }, + review: { color: 'blue', text: '审核中' }, + published: { color: 'green', text: '已发布' }, + withdrawn: { color: 'default', text: '已撤回' }, +}; + const canWrite = () => authService.hasPermission('knowledge:write'); function DiseaseTab() { @@ -63,6 +70,7 @@ function DiseaseTab() { width: 80, render: (_, r) => (r.enabled === false ? 停用 : 启用), }, + { title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? {STATUS_LABELS[r.status]?.text || r.status} : '-') }, { title: '操作', valueType: 'option', @@ -254,6 +262,8 @@ function ArticleTab() { render: (_, r) => {KIND_LABELS[r.kind] || r.kind}, }, { title: '摘要', dataIndex: 'summary', search: false, ellipsis: true, render: (_, r) => r.summary || '-' }, + { title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? {STATUS_LABELS[r.status]?.text || r.status} : '-') }, + { title: '来源', dataIndex: 'source', search: false, render: (_, r) => r.source || '-' }, { title: '排序', dataIndex: 'sortOrder', search: false, width: 70 }, { title: '操作', diff --git a/web/src/pages/Traces.tsx b/web/src/pages/Traces.tsx index 26814e7..7a92c06 100644 --- a/web/src/pages/Traces.tsx +++ b/web/src/pages/Traces.tsx @@ -20,6 +20,7 @@ import { type RegionStat, } from '../dal/trace'; import { getMonthlyStats } from '../dal/health'; +import { getRoomEffect, type EffectReport } from '../dal/health'; import { authService } from '../services/auth'; const STATUS_LABELS: Record = { @@ -40,6 +41,8 @@ export default function TracesPage() { const [detail, setDetail] = useState(null); const [checklist, setChecklist] = useState([]); const [answers, setAnswers] = useState>({}); + const [effect, setEffect] = useState(null); + const [effectRoomId, setEffectRoomId] = useState(''); useEffect(() => { getRegionStats(90) @@ -94,6 +97,12 @@ export default function TracesPage() { setAnswers({}); }; + const openEffect = async (r: TraceRecord) => { + if (!r.roomId) return; + setEffectRoomId(r.roomId); + setEffect(await getRoomEffect(r.roomId).catch(() => null)); + }; + const columns: ProColumns[] = [ { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, { title: '病种', dataIndex: 'disease' }, @@ -126,6 +135,11 @@ export default function TracesPage() { openDetail(r)}> 详情 , + r.roomId ? ( + openEffect(r)}> + 效果 + + ) : null, ...(canWrite() ? [ r.status === 'pending' ? ( @@ -242,6 +256,29 @@ export default function TracesPage() { + setEffect(null)} + footer={} + > + {effect ? ( +
+ + 结论:{effect.conclusion} +
+ 处置前风险均值:{effect.beforeRiskAvg !== undefined ? effect.beforeRiskAvg.toFixed(1) : '缺失'} +
+ 处置后风险均值:{effect.afterRiskAvg !== undefined ? effect.afterRiskAvg.toFixed(1) : '缺失'} +
+ 复发次数:{effect.recurrences} +
+ 缺失维度:{effect.missing.join('、') || '无'} +
+
+ ) : null} +
+ setDetail(null)}> {detail ? (
diff --git a/后续工作计划.md b/后续工作计划.md index c6020bd..f0131b4 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -1,6 +1,6 @@ # 后续工作计划 -> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 +> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 ## 整改实施计划 Wave 0-4(2026-08-13 启动) @@ -20,7 +20,7 @@ | Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 | | Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 | | Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 | -| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 | +| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 | | Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 | | Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index e179794..2756acf 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -1088,3 +1088,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 - 本任务前分支提交为 `6b222ab`;回滚可还原 Task 11 提交; - 数据库回滚执行 `000007_inspection_idempotency.down.sql` 可恢复全局唯一索引,但已被迁移追加后缀的历史键不会自动还原; - 小程序回滚需还原队列服务、巡检页、请求刷新逻辑和 store,并移除 `scripts/verify.ps1` 中的 `miniapp test` 步骤。 + +## 2026-08-14 整改 Task 12:完善环境规则、会诊治理、知识审核与效果评估 + +### 做了什么 + +- 新增规则引擎 `EvaluateRules`:支持版本化规则、连续时窗、数据新鲜度、缺失项和输入快照,单个最新值不能冒充连续高湿; +- 会诊状态机改为 `unassigned/assigned/accepted/needs_info/resolved/archived`,分派时写入 SLA 截止时间,支持 `overdue/on_time/unscheduled` 判断; +- 新增 `consultation_opinion_versions` 表,专家每次出方案/修改保留作者、时间、版本和原因; +- 知识文章和病种新增 `status/source/sourceDate/expertConfirmedBy`,普通用户只读已发布内容,草稿/审核/撤回仅审核权限用户可见; +- 新增 `EvaluateControlEffect` 和 `/health-profiles/:roomId/effect` 效果报告 API,对比处置前后巡检风险、检测阳性率和复发,缺失维度不按 0 当改善; +- Web 会诊页适配新状态机和 SLA;知识页展示审核状态与来源;溯源页新增效果报告入口。 + +### 设计思路与决策依据 + +- 规则输出必须可复盘:版本、计算时间、输入快照、缺失项都保留;缺失输入降低结论可信度而不是填 0; +- SLA 超时只产生状态标记/升级事件,不自动伪造专家结论; +- 知识内容先默认草稿,经过审核后发布;撤回后不再进入普通知识列表,历史病例引用仍保留快照; +- 效果评估明确区分“改善/未改善/证据不足”,避免缺失数据被解释为防控有效。 + +### 验证结果 + +- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过; +- 新增测试覆盖连续 3 天高湿、过期遥测缺失、规则快照、会诊归档/意见版本/SLA、知识状态机、效果报告改善与缺失; +- 未部署开发服务器,未执行 `000008` 迁移;未接入真实领域专家审核和 SLA 升级通知。 + +### 回滚点 + +- 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交; +- 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列; +- Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。