feat: 完善环境规则、会诊治理、知识审核与效果评估

This commit is contained in:
weijuesen
2026-08-14 10:33:17 +08:00
parent 69ef5a0704
commit 126c215a6b
27 changed files with 963 additions and 88 deletions
+1
View File
@@ -92,6 +92,7 @@
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
- Web「专家会诊」页
- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告
### 1.13 疫病溯源与区域统计(计划 #21/#22)
+9 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
)
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
const CurrentSchemaVersion = "7"
const CurrentSchemaVersion = "8"
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
func RunMigrations(db *gorm.DB) error {
@@ -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)
}
}
+43 -7
View File
@@ -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) {
@@ -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 不应通过")
}
}
+54
View File
@@ -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))
}
}
+49
View File
@@ -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) {
+49 -27
View File
@@ -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/resolvedconsulting→resolvedresolvedarchived
// 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"`
}
+38 -9
View File
@@ -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 {
+49
View File
@@ -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" }
+53 -29
View File
@@ -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
}
}
@@ -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 不能回草稿")
}
}
@@ -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))
}
@@ -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)
}
}
+150
View File
@@ -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
}
@@ -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")
}
}
@@ -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;
@@ -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;
+7 -1
View File
@@ -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<Consultation>) =>
post<Consultation>('/consultations', data);
export const updateConsultation = (id: string, data: Partial<Consultation>) =>
patch<Consultation>(`/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<Consultation>(`/consultations/${id}/resolve`, data);
export const archiveConsultation = (id: string) => post<Consultation>(`/consultations/${id}/archive`);
+14
View File
@@ -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<HealthProfile>(`/health-profiles/${roomId}`);
export const getRoomEffect = (roomId: string, params?: any) =>
get<EffectReport>(`/health-profiles/${roomId}/effect`, { params });
export const getMonthlyStats = (year?: number) =>
get<{ year: number; stats: { month: string; total: number; diseases: Record<string, number> }[] }>(
'/trace-records/monthly-stats',
+8
View File
@@ -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;
+19 -5
View File
@@ -14,8 +14,10 @@ import {
import { authService } from '../services/auth';
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
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() {
</a>,
...(canWrite()
? [
r.status === 'pending' ? (
r.status === 'unassigned' ? (
<a
key="take"
onClick={async () => {
await updateConsultation(r.id, { status: 'consulting' });
await updateConsultation(r.id, { status: 'assigned' });
message.success('已受理');
actionRef.current?.reload();
}}
>
</a>
) : null,
r.status === 'assigned' ? (
<a
key="accept"
onClick={async () => {
await updateConsultation(r.id, { status: 'accepted' });
message.success('已受理');
actionRef.current?.reload();
}}
@@ -65,7 +79,7 @@ export default function ConsultationsPage() {
</a>
) : null,
r.status === 'pending' || r.status === 'consulting' ? (
r.status === 'accepted' || r.status === 'needs_info' || r.status === 'assigned' ? (
<a
key="resolve"
onClick={() => {
+10
View File
@@ -33,6 +33,13 @@ const KIND_LABELS: Record<string, string> = {
seasonal_tip: '季节性提醒',
};
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
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 ? <Tag color="default"></Tag> : <Tag color="green"></Tag>),
},
{ title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag> : '-') },
{
title: '操作',
valueType: 'option',
@@ -254,6 +262,8 @@ function ArticleTab() {
render: (_, r) => <Tag>{KIND_LABELS[r.kind] || r.kind}</Tag>,
},
{ title: '摘要', dataIndex: 'summary', search: false, ellipsis: true, render: (_, r) => r.summary || '-' },
{ title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag> : '-') },
{ title: '来源', dataIndex: 'source', search: false, render: (_, r) => r.source || '-' },
{ title: '排序', dataIndex: 'sortOrder', search: false, width: 70 },
{
title: '操作',
+37
View File
@@ -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<string, { color: string; text: string }> = {
@@ -40,6 +41,8 @@ export default function TracesPage() {
const [detail, setDetail] = useState<TraceRecord | null>(null);
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [effect, setEffect] = useState<EffectReport | null>(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<TraceRecord>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '病种', dataIndex: 'disease' },
@@ -126,6 +135,11 @@ export default function TracesPage() {
<a key="view" onClick={() => openDetail(r)}>
</a>,
r.roomId ? (
<a key="effect" onClick={() => openEffect(r)}>
</a>
) : null,
...(canWrite()
? [
r.status === 'pending' ? (
@@ -242,6 +256,29 @@ export default function TracesPage() {
</Form>
</Modal>
<Modal
title={`防控效果报告:${effectRoomId}`}
open={!!effect}
onCancel={() => setEffect(null)}
footer={<Button onClick={() => setEffect(null)}></Button>}
>
{effect ? (
<div>
<Typography.Paragraph>
<b>{effect.conclusion}</b>
<br />
{effect.beforeRiskAvg !== undefined ? effect.beforeRiskAvg.toFixed(1) : '缺失'}
<br />
{effect.afterRiskAvg !== undefined ? effect.afterRiskAvg.toFixed(1) : '缺失'}
<br />
{effect.recurrences}
<br />
{effect.missing.join('、') || '无'}
</Typography.Paragraph>
</div>
) : null}
</Modal>
<Drawer title={`溯源详情 - ${detail?.disease || ''}`} open={!!detail} width={680} onClose={() => setDetail(null)}>
{detail ? (
<div>
+2 -2
View File
@@ -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-42026-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 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
+30
View File
@@ -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 0Go 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,不覆盖旧专家意见。