feat(server-go): 蚕房健康画像与年度发病统计(#24)

This commit is contained in:
weijuesen
2026-08-13 13:34:13 +08:00
parent 1a6bcd27fe
commit cbcb22e851
5 changed files with 252 additions and 6 deletions
+85 -5
View File
@@ -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,
})
}
}
+26
View File
@@ -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) {
@@ -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
}
@@ -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("空输入应返回空")
}
}