diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 2938cda..9bf81d9 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -91,7 +91,7 @@ func main() { api.Use(middleware.Auth(cfg)) // 公开路由(auth 白名单中跳过鉴权) - handler.RegisterHealthRoutes(api) + handler.RegisterHealthRoutes(api, db) handler.RegisterVideoStreamRoutes(api, transcodeSvc, db, mediaSvc, cfg) // JWT 保护的业务路由 @@ -119,6 +119,7 @@ func main() { handler.RegisterConsultationRoutes(api, db) handler.RegisterDetectionMethodRoutes(api, db) handler.RegisterTraceRoutes(api, db) + handler.RegisterHealthRoutes(api, db) // 启动高发病天气预警定时任务(未配置时跳过) go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) diff --git a/server-go/internal/handler/health.go b/server-go/internal/handler/health.go index 72f7605..639d01a 100644 --- a/server-go/internal/handler/health.go +++ b/server-go/internal/handler/health.go @@ -2,13 +2,93 @@ package handler import ( "net/http" + "time" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + "silk-server-go/internal/service" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) -// RegisterHealthRoutes 注册健康检查路由 -func RegisterHealthRoutes(rg *gin.RouterGroup) { - rg.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) +// RegisterHealthRoutes 注册蚕房健康画像路由 +func RegisterHealthRoutes(rg *gin.RouterGroup, db *gorm.DB) { + rg.GET("/rooms/:id/health-profile", + middleware.RequirePermission(db, "room:read"), + roomHealthProfile(db)) +} + +// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分 +func roomHealthProfile(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var room model.Room + if db.Where("id = ?", id).First(&room).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "room not found"}) + return + } + since := time.Now().Add(-30 * 24 * time.Hour) + + // 近 30 天巡检风险分布 + var riskRows []struct { + RiskLevel string `gorm:"column:risk_level"` + Cnt int64 `gorm:"column:cnt"` + } + db.Table("inspection_records"). + Select("risk_level, count(*) AS cnt"). + Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ?", id, since). + Group("risk_level"). + Scan(&riskRows) + riskCounts := map[string]int64{} + var riskLevels []string + for _, r := range riskRows { + riskCounts[r.RiskLevel] = r.Cnt + for i := int64(0); i < r.Cnt; i++ { + riskLevels = append(riskLevels, r.RiskLevel) + } + } + + // 近 30 天 LAMP/分子检测结果 + lampTotal, lampPositive := int64(0), int64(0) + db.Model(&model.LampTest{}). + Where("room_id = ? AND status = 'resulted' AND created_at >= ?", id, since). + Count(&lampTotal) + db.Model(&model.LampTest{}). + Where("room_id = ? AND status = 'resulted' AND result = 'positive' AND created_at >= ?", id, since). + Count(&lampPositive) + + // 事件数 + var consultationCount, traceCount int64 + db.Model(&model.Consultation{}).Where("room_id = ?", id).Count(&consultationCount) + db.Model(&model.TraceRecord{}).Where("room_id = ?", id).Count(&traceCount) + + // 当前批次 + var batch *model.Batch + var latest model.Batch + if db.Where("room_id = ?", id).Order("created_at DESC").First(&latest).Error == nil { + batch = &latest + } + + score, grade := service.ComputeHealthScore(service.HealthInput{ + RecentRiskLevels: riskLevels, + LampPositive: int(lampPositive), + LampTotal: int(lampTotal), + ConsultationCount: int(consultationCount), + TraceCount: int(traceCount), + }) + c.JSON(http.StatusOK, gin.H{ + "roomId": room.ID, + "roomName": room.Name, + "score": score, + "grade": grade, + "riskDistribution": gin.H{ + "green": riskCounts["green"], "yellow": riskCounts["yellow"], + "orange": riskCounts["orange"], "red": riskCounts["red"], + }, + "lampResults": gin.H{"total": lampTotal, "positive": lampPositive}, + "events": gin.H{"consultations": consultationCount, "traces": traceCount}, + "batch": batch, + }) + } } diff --git a/server-go/internal/handler/trace.go b/server-go/internal/handler/trace.go index 8544d93..ae51d32 100644 --- a/server-go/internal/handler/trace.go +++ b/server-go/internal/handler/trace.go @@ -20,6 +20,7 @@ func RegisterTraceRoutes(rg *gin.RouterGroup, db *gorm.DB) { write := middleware.RequirePermission(db, "trace:write") rg.GET("/trace-records", read, listTraceRecords(db)) rg.GET("/trace-records/region-stats", read, traceRegionStats(db)) + rg.GET("/trace-records/monthly-stats", read, traceMonthlyStats(db)) rg.GET("/trace-records/:id", read, getTraceRecord(db)) rg.POST("/trace-records", write, createTraceRecord(db)) rg.PATCH("/trace-records/:id", write, updateTraceRecord(db)) @@ -29,6 +30,31 @@ func RegisterTraceRoutes(rg *gin.RouterGroup, db *gorm.DB) { rg.POST("/trace-records/:id/checklist", write, submitTraceChecklist(db)) } +// traceMonthlyStats 年度发病规律(按月聚合) +func traceMonthlyStats(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + year := time.Now().Year() + if y, err := strconv.Atoi(c.DefaultQuery("year", "0")); err == nil && y >= 2020 && y <= 2100 { + year = y + } + start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local) + end := start.AddDate(1, 0, 0) + var rows []struct { + Month string `gorm:"column:month"` + Disease string `gorm:"column:disease"` + } + db.Table("trace_records"). + Select("to_char(created_at, 'YYYY-MM') AS month, disease"). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&rows) + entries := make([]service.MonthDiseaseEntry, 0, len(rows)) + for _, r := range rows { + entries = append(entries, service.MonthDiseaseEntry{Month: r.Month, Disease: r.Disease}) + } + c.JSON(http.StatusOK, gin.H{"year": year, "stats": service.AggregateMonthlyStats(entries)}) + } +} + // traceRegionStats 区域发病统计(近 N 天,按蚕房 region 聚合) func traceRegionStats(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/service/health_profile.go b/server-go/internal/service/health_profile.go new file mode 100644 index 0000000..81fa8d6 --- /dev/null +++ b/server-go/internal/service/health_profile.go @@ -0,0 +1,87 @@ +package service + +import ( + "sort" + "strings" +) + +// HealthInput 健康画像输入 +type HealthInput struct { + RecentRiskLevels []string // 近 30 天巡检风险等级 + LampPositive int + LampTotal int + ConsultationCount int + TraceCount int +} + +// ComputeHealthScore 综合健康分(0-100)与评级(优/良/中/差) +func ComputeHealthScore(in HealthInput) (float64, string) { + score := 100.0 + for _, level := range in.RecentRiskLevels { + switch level { + case "red": + score -= 25 + case "orange": + score -= 12 + case "yellow": + score -= 4 + } + } + if in.LampTotal > 0 { + score -= float64(in.LampPositive) / float64(in.LampTotal) * 30 + } + score -= float64(in.ConsultationCount) * 10 + score -= float64(in.TraceCount) * 8 + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + grade := "差" + switch { + case score >= 85: + grade = "优" + case score >= 70: + grade = "良" + case score >= 55: + grade = "中" + } + return score, grade +} + +// MonthDiseaseEntry 月度发病条目 +type MonthDiseaseEntry struct { + Month string + Disease string +} + +// MonthStat 月度发病统计 +type MonthStat struct { + Month string `json:"month"` + Total int `json:"total"` + Diseases map[string]int `json:"diseases"` +} + +// AggregateMonthlyStats 按月份聚合发病数与病种分布(升序) +func AggregateMonthlyStats(entries []MonthDiseaseEntry) []MonthStat { + byMonth := make(map[string]*MonthStat) + for _, e := range entries { + if strings.TrimSpace(e.Month) == "" { + continue + } + s, ok := byMonth[e.Month] + if !ok { + s = &MonthStat{Month: e.Month, Diseases: map[string]int{}} + byMonth[e.Month] = s + } + s.Total++ + s.Diseases[e.Disease]++ + } + result := make([]MonthStat, 0, len(byMonth)) + for _, s := range byMonth { + result = append(result, *s) + } + sort.Slice(result, func(i, j int) bool { return result[i].Month < result[j].Month }) + return result +} diff --git a/server-go/internal/service/health_profile_test.go b/server-go/internal/service/health_profile_test.go new file mode 100644 index 0000000..8588adf --- /dev/null +++ b/server-go/internal/service/health_profile_test.go @@ -0,0 +1,52 @@ +package service + +import "testing" + +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, + ConsultationCount: 1, + TraceCount: 1, + } + s, g := ComputeHealthScore(bad) + if s >= 80 { + t.Errorf("风险输入应显著扣分,实际 %.1f", s) + } + if g == "优" { + t.Errorf("风险输入不应评级为优,实际 %s", g) + } +} + +func TestComputeHealthScoreClamp(t *testing.T) { + s, _ := ComputeHealthScore(HealthInput{RecentRiskLevels: []string{"red", "red", "red", "red", "red"}}) + if s < 0 { + t.Errorf("分数不应为负,实际 %.1f", s) + } +} + +func TestAggregateMonthlyStats(t *testing.T) { + entries := []MonthDiseaseEntry{ + {Month: "2026-07", Disease: "白僵病"}, + {Month: "2026-07", Disease: "白僵病"}, + {Month: "2026-07", Disease: "软化病"}, + {Month: "2026-08", Disease: "白僵病"}, + } + stats := AggregateMonthlyStats(entries) + if len(stats) != 2 { + t.Fatalf("月份数 = %d, want 2", len(stats)) + } + if stats[0].Month != "2026-07" || stats[0].Total != 3 { + t.Errorf("2026-07 应 total=3,实际 %+v", stats[0]) + } + if stats[0].Diseases["白僵病"] != 2 || stats[0].Diseases["软化病"] != 1 { + t.Errorf("2026-07 病种分布不正确: %+v", stats[0].Diseases) + } + if len(AggregateMonthlyStats(nil)) != 0 { + t.Error("空输入应返回空") + } +}