diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 480baad..a5df681 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -74,6 +74,7 @@ func main() { transcodeSvc := service.NewTranscodeService(db, s3Svc) aiSvc := service.NewAIClient(cfg.AIServiceBase) wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret) + weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation) // 9. 创建 Gin 引擎 gin.SetMode(gin.ReleaseMode) @@ -112,6 +113,10 @@ func main() { handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection) handler.RegisterTrayBatchRoutes(api, db) handler.RegisterWechatRoutes(api, db, wechatSvc) + handler.RegisterWeatherRoutes(api, db, weatherSvc) + + // 启动高发病天气预警定时任务(未配置时跳过) + go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute) // 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态) go startDeviceStatusSync(db, mediaSvc) @@ -179,3 +184,35 @@ func startDeviceStatusSync(db *gorm.DB, media *service.MediaService) { } } } + +// startWeatherAlertLoop 定期拉取天气并按规则写入 weather_alerts +func startWeatherAlertLoop(db *gorm.DB, weather *service.WeatherService, interval time.Duration) { + if !weather.Configured() { + slog.Warn("天气服务未配置(QWEATHER_API_KEY/QWEATHER_LOCATION),跳过定时预警") + return + } + run := func() { + ctx := context.Background() + now, err := weather.FetchNow(ctx) + if err != nil { + slog.Warn("天气拉取失败", "err", err) + return + } + daily, err := weather.FetchDaily(ctx) + if err != nil { + slog.Warn("天气预报拉取失败", "err", err) + return + } + for _, a := range service.EvaluateWeatherRules(*now, daily, "") { + if err := db.Create(&a).Error; err != nil { + slog.Warn("天气预警写入失败", "disease", a.Disease, "err", err) + } + } + } + run() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + run() + } +} diff --git a/server-go/internal/config/config.go b/server-go/internal/config/config.go index 24fe8ce..270287b 100644 --- a/server-go/internal/config/config.go +++ b/server-go/internal/config/config.go @@ -29,6 +29,9 @@ type Config struct { WechatSecret string `env:"WECHAT_SECRET" envDefault:""` WechatTemplateAlarm string `env:"WECHAT_TEMPLATE_ALARM" envDefault:""` WechatTemplateInspection string `env:"WECHAT_TEMPLATE_INSPECTION" envDefault:""` + QWeatherAPIKey string `env:"QWEATHER_API_KEY" envDefault:""` + QWeatherLocation string `env:"QWEATHER_LOCATION" envDefault:""` + QWeatherIntervalMin int `env:"QWEATHER_INTERVAL_MIN" envDefault:"30"` InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"` Port int `env:"PORT" envDefault:"3000"` DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"` diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index cd23483..003de70 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -31,6 +31,7 @@ func Init(cfg *config.Config) error { &model.InspectionRecord{}, &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, + &model.WeatherAlert{}, ); err != nil { slog.Warn("自动迁移有警告(可忽略)", "err", err) } diff --git a/server-go/internal/handler/weather.go b/server-go/internal/handler/weather.go new file mode 100644 index 0000000..df7d7a8 --- /dev/null +++ b/server-go/internal/handler/weather.go @@ -0,0 +1,52 @@ +package handler + +import ( + "net/http" + "strconv" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + "silk-server-go/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RegisterWeatherRoutes 注册天气与天气预警路由 +func RegisterWeatherRoutes(rg *gin.RouterGroup, db *gorm.DB, weather *service.WeatherService) { + read := middleware.RequirePermission(db, "weather:read") + rg.GET("/weather/now", read, weatherNow(weather)) + rg.GET("/weather/alerts", read, listWeatherAlerts(db)) +} + +// weatherNow 实时天气 + 规则评估(实时触发;stage 可选:late5 等) +func weatherNow(weather *service.WeatherService) gin.HandlerFunc { + return func(c *gin.Context) { + now, err := weather.FetchNow(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + daily, err := weather.FetchDaily(c.Request.Context()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + stage := c.Query("stage") + alerts := service.EvaluateWeatherRules(*now, daily, stage) + c.JSON(http.StatusOK, gin.H{"weather": now, "alerts": alerts}) + } +} + +// listWeatherAlerts 最近天气预警 +func listWeatherAlerts(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + limit := 20 + if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 { + limit = l + } + var list []model.WeatherAlert + db.Order("created_at DESC").Limit(limit).Find(&list) + c.JSON(http.StatusOK, list) + } +} diff --git a/server-go/internal/model/permission_seed.go b/server-go/internal/model/permission_seed.go index 7da27c3..81df015 100644 --- a/server-go/internal/model/permission_seed.go +++ b/server-go/internal/model/permission_seed.go @@ -34,6 +34,7 @@ var AllPermissions = []PermissionDef{ {"rearing:write", "饲养记录管理", "新增、编辑、删除饲养记录"}, {"notification:read", "订阅查看", "查看微信订阅绑定状态"}, {"notification:write", "订阅管理", "绑定微信并管理订阅授权"}, + {"weather:read", "天气查看", "查看天气与高发病天气预警"}, {"user:manage", "用户管理", "管理用户、角色和权限"}, {"audit:read", "审计查看", "查看审计日志"}, } @@ -48,6 +49,7 @@ var RolePermissionMap = map[string][]string{ "inspection:create", "inspection:read", "tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write", "notification:read", "notification:write", + "weather:read", "user:manage", "audit:read", }, RoleOperator: { @@ -58,6 +60,7 @@ var RolePermissionMap = map[string][]string{ "inspection:create", "inspection:read", "tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write", "notification:read", "notification:write", + "weather:read", }, RoleViewer: { "dashboard:view", "room:read", "device:read", @@ -66,6 +69,7 @@ var RolePermissionMap = map[string][]string{ "inspection:read", "tray:read", "batch:read", "rearing:read", "notification:read", + "weather:read", }, RoleFarmer: { "dashboard:view", "room:read", "device:read", "device:control", @@ -74,5 +78,6 @@ var RolePermissionMap = map[string][]string{ "inspection:create", "inspection:read", "tray:read", "batch:read", "rearing:read", "notification:read", "notification:write", + "weather:read", }, } diff --git a/server-go/internal/model/weather_alert.go b/server-go/internal/model/weather_alert.go new file mode 100644 index 0000000..3a61de7 --- /dev/null +++ b/server-go/internal/model/weather_alert.go @@ -0,0 +1,18 @@ +package model + +import ( + "encoding/json" + "time" +) + +// WeatherAlert 高发病天气预警 +type WeatherAlert struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Disease string `gorm:"size:64" json:"disease"` + Level string `gorm:"size:16" json:"level"` + Reason string `gorm:"type:text" json:"reason"` + Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` +} + +func (WeatherAlert) TableName() string { return "weather_alerts" } diff --git a/server-go/internal/service/weather.go b/server-go/internal/service/weather.go new file mode 100644 index 0000000..2e6fc58 --- /dev/null +++ b/server-go/internal/service/weather.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const qweatherAPIBase = "https://devapi.qweather.com" + +// WeatherNow 实时天气 +type WeatherNow struct { + Temp float64 `json:"temp"` + Humidity float64 `json:"humidity"` + Text string `json:"text"` +} + +// DailyForecast 逐日预报 +type DailyForecast struct { + Date string `json:"date"` + TextDay string `json:"textDay"` + TextNight string `json:"textNight"` + TempMax float64 `json:"tempMax"` + TempMin float64 `json:"tempMin"` + Humidity float64 `json:"humidity"` +} + +// WeatherService 和风天气客户端(未配置时 Configured()=false,调用返回明确错误) +type WeatherService struct { + key string + location string + baseURL string + client *http.Client +} + +// NewWeatherService 创建和风天气客户端 +func NewWeatherService(key, location string) *WeatherService { + return &WeatherService{ + key: key, + location: location, + baseURL: qweatherAPIBase, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Configured 是否已配置 API Key 与位置 +func (s *WeatherService) Configured() bool { + return s.key != "" && s.location != "" +} + +// FetchNow 实时天气 +func (s *WeatherService) FetchNow(ctx context.Context) (*WeatherNow, error) { + var out struct { + Code string `json:"code"` + Now struct { + Temp string `json:"temp"` + Text string `json:"text"` + Humidity string `json:"humidity"` + } `json:"now"` + } + if err := s.get(ctx, "/v7/weather/now", &out); err != nil { + return nil, err + } + temp, _ := strconv.ParseFloat(out.Now.Temp, 64) + hum, _ := strconv.ParseFloat(out.Now.Humidity, 64) + return &WeatherNow{Temp: temp, Humidity: hum, Text: out.Now.Text}, nil +} + +// FetchDaily 未来 3 天预报 +func (s *WeatherService) FetchDaily(ctx context.Context) ([]DailyForecast, error) { + var out struct { + Code string `json:"code"` + Daily []struct { + FxDate string `json:"fxDate"` + TextDay string `json:"textDay"` + TextNight string `json:"textNight"` + TempMax string `json:"tempMax"` + TempMin string `json:"tempMin"` + Humidity string `json:"humidity"` + } `json:"daily"` + } + if err := s.get(ctx, "/v7/weather/3d", &out); err != nil { + return nil, err + } + list := make([]DailyForecast, 0, len(out.Daily)) + for _, d := range out.Daily { + max, _ := strconv.ParseFloat(d.TempMax, 64) + min, _ := strconv.ParseFloat(d.TempMin, 64) + hum, _ := strconv.ParseFloat(d.Humidity, 64) + list = append(list, DailyForecast{ + Date: d.FxDate, TextDay: d.TextDay, TextNight: d.TextNight, + TempMax: max, TempMin: min, Humidity: hum, + }) + } + return list, nil +} + +// get 发起 GET 并校验 code=200 +func (s *WeatherService) get(ctx context.Context, path string, out any) error { + if !s.Configured() { + return fmt.Errorf("天气服务未配置(QWEATHER_API_KEY/QWEATHER_LOCATION)") + } + u := s.baseURL + path + "?location=" + url.QueryEscape(s.location) + "&key=" + url.QueryEscape(s.key) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.Unmarshal(body, out); err != nil { + return err + } + var head struct { + Code string `json:"code"` + } + _ = json.Unmarshal(body, &head) + if head.Code != "200" { + return fmt.Errorf("和风天气返回错误 code=%s", head.Code) + } + return nil +} diff --git a/server-go/internal/service/weather_risk.go b/server-go/internal/service/weather_risk.go new file mode 100644 index 0000000..1a727be --- /dev/null +++ b/server-go/internal/service/weather_risk.go @@ -0,0 +1,68 @@ +package service + +import ( + "fmt" + "strings" + + "silk-server-go/internal/model" +) + +// WeatherTextRainy 天气现象是否降雨/雷/雪 +func WeatherTextRainy(text string) bool { + return strings.Contains(text, "雨") || strings.Contains(text, "雷") || strings.Contains(text, "雪") +} + +// RainDays 预报中降雨天数 +func RainDays(daily []DailyForecast) int { + n := 0 + for _, d := range daily { + if WeatherTextRainy(d.TextDay) || WeatherTextRainy(d.TextNight) { + n++ + } + } + return n +} + +// FungalRisk 白僵病:湿度 ≥80% 且(连续阴雨 ≥2 天 或 当前降雨) +func FungalRisk(now WeatherNow, daily []DailyForecast) (string, string) { + if now.Humidity >= 80 && (RainDays(daily) >= 2 || WeatherTextRainy(now.Text)) { + return "orange", fmt.Sprintf("实时湿度 %.0f%% 且未来 %d 天有降雨(连续阴雨),符合白僵病高发条件(湿度大于80)", now.Humidity, RainDays(daily)) + } + return "", "" +} + +// TempSwingRisk 核型多角体病:温度突变(>30℃ 或 <20℃) +func TempSwingRisk(temp float64) (string, string) { + if temp > 30 || temp < 20 { + return "yellow", fmt.Sprintf("实时温度 %.0f℃ 波动剧烈(>30℃ 或 <20℃),易诱发核型多角体病潜伏感染转急性", temp) + } + return "", "" +} + +// SofteningRisk 软化病:湿度 >75%(结合密度/通风可升级) +func SofteningRisk(now WeatherNow) (string, string) { + if now.Humidity >= 75 { + return "yellow", fmt.Sprintf("实时湿度 %.0f%%(大于75),若蚕头密度过大、通风不良易发软化病", now.Humidity) + } + return "", "" +} + +// EvaluateWeatherRules 综合天气规则 → 预警列表(规则取自规格书 3.2.3) +func EvaluateWeatherRules(now WeatherNow, daily []DailyForecast, stage string) []model.WeatherAlert { + var alerts []model.WeatherAlert + + if level, reason := FungalRisk(now, daily); level != "" { + alerts = append(alerts, model.WeatherAlert{Disease: "白僵病", Level: level, Reason: reason}) + } + if level, reason := TempSwingRisk(now.Temp); level != "" { + if stage == "late5" { + level = "orange" + reason += ";当前为 5 龄后期,风险升级" + } + alerts = append(alerts, model.WeatherAlert{Disease: "核型多角体病", Level: level, Reason: reason}) + } + if level, reason := SofteningRisk(now); level != "" { + alerts = append(alerts, model.WeatherAlert{Disease: "软化病", Level: level, Reason: reason}) + } + return alerts +} diff --git a/server-go/internal/service/weather_risk_test.go b/server-go/internal/service/weather_risk_test.go new file mode 100644 index 0000000..992d768 --- /dev/null +++ b/server-go/internal/service/weather_risk_test.go @@ -0,0 +1,77 @@ +package service + +import "testing" + +func TestRainDays(t *testing.T) { + daily := []DailyForecast{ + {Date: "2026-08-12", TextDay: "小雨", TextNight: "阴"}, + {Date: "2026-08-13", TextDay: "多云", TextNight: "晴"}, + {Date: "2026-08-14", TextDay: "雷阵雨", TextNight: "小雨"}, + } + if n := RainDays(daily); n != 2 { + t.Errorf("RainDays = %d, want 2", n) + } + if n := RainDays(nil); n != 0 { + t.Errorf("空预报 RainDays = %d, want 0", n) + } +} + +func TestWeatherTextRainy(t *testing.T) { + if !WeatherTextRainy("小雨") { + t.Error("小雨应判为降雨") + } + if !WeatherTextRainy("雷阵雨") { + t.Error("雷阵雨应判为降雨") + } + if WeatherTextRainy("晴") { + t.Error("晴不应判为降雨") + } +} + +func TestFungalRisk(t *testing.T) { + // 湿度 85 + 未来 2 天降雨 → 白僵病高风险 + now := WeatherNow{Temp: 27, Humidity: 85, Text: "小雨"} + daily := []DailyForecast{ + {TextDay: "小雨"}, {TextDay: "中雨"}, {TextDay: "多云"}, + } + level, reason := FungalRisk(now, daily) + if level != "orange" { + t.Errorf("FungalRisk level = %s, want orange(原因 %s)", level, reason) + } + // 湿度正常 → 无 + now2 := WeatherNow{Temp: 27, Humidity: 60, Text: "晴"} + if l, _ := FungalRisk(now2, daily); l != "" { + t.Errorf("湿度 60 不应触发白僵病: %s", l) + } +} + +func TestTempSwingRisk(t *testing.T) { + if l, _ := TempSwingRisk(32); l != "yellow" { + t.Errorf("32℃ 应为 yellow,实际 %s", l) + } + if l, _ := TempSwingRisk(18); l != "yellow" { + t.Errorf("18℃ 应为 yellow,实际 %s", l) + } + if l, _ := TempSwingRisk(25); l != "" { + t.Errorf("25℃ 不应触发,实际 %s", l) + } +} + +func TestEvaluateWeatherRules(t *testing.T) { + now := WeatherNow{Temp: 32, Humidity: 85, Text: "小雨"} + daily := []DailyForecast{{TextDay: "小雨"}, {TextDay: "中雨"}, {TextDay: "多云"}} + alerts := EvaluateWeatherRules(now, daily, "late5") + if len(alerts) == 0 { + t.Fatal("高温高湿+阴雨应产生预警") + } + found := map[string]bool{} + for _, a := range alerts { + found[a.Disease] = true + if a.Reason == "" { + t.Errorf("预警缺少原因: %+v", a) + } + } + if !found["白僵病"] { + t.Error("缺少白僵病预警") + } +} diff --git a/server-go/internal/service/weather_test.go b/server-go/internal/service/weather_test.go new file mode 100644 index 0000000..f8f0caf --- /dev/null +++ b/server-go/internal/service/weather_test.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWeatherServiceConfigured(t *testing.T) { + if NewWeatherService("", "").Configured() { + t.Error("空配置应返回 false") + } + if !NewWeatherService("key", "101010100").Configured() { + t.Error("有配置应返回 true") + } +} + +func TestWeatherFetchNow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v7/weather/now" { + t.Errorf("path = %s", r.URL.Path) + } + if r.URL.Query().Get("key") != "key1" || r.URL.Query().Get("location") == "" { + t.Errorf("参数不正确: %v", r.URL.Query()) + } + _, _ = w.Write([]byte(`{"code":"200","now":{"temp":"28","text":"小雨","humidity":"85"}}`)) + })) + defer srv.Close() + + s := NewWeatherService("key1", "116.41,39.92") + s.baseURL = srv.URL + now, err := s.FetchNow(context.Background()) + if err != nil { + t.Fatalf("FetchNow 错误: %v", err) + } + if now.Temp != 28 || now.Humidity != 85 || now.Text != "小雨" { + t.Errorf("解析结果不正确: %+v", now) + } +} + +func TestWeatherFetchDaily(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":"200","daily":[{"fxDate":"2026-08-12","textDay":"小雨","textNight":"阴","tempMax":"30","tempMin":"24","humidity":"78"}]}`)) + })) + defer srv.Close() + + s := NewWeatherService("key1", "101010100") + s.baseURL = srv.URL + daily, err := s.FetchDaily(context.Background()) + if err != nil { + t.Fatalf("FetchDaily 错误: %v", err) + } + if len(daily) != 1 || daily[0].TextDay != "小雨" || daily[0].TempMax != 30 { + t.Errorf("解析结果不正确: %+v", daily) + } +} + +func TestWeatherFetchErrorCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":"401","message":"invalid key"}`)) + })) + defer srv.Close() + + s := NewWeatherService("bad", "101010100") + s.baseURL = srv.URL + if _, err := s.FetchNow(context.Background()); err == nil { + t.Error("code!=200 应返回错误") + } +}