feat(server-go): 和风天气接入与高发病天气预警规则(#12)
This commit is contained in:
@@ -74,6 +74,7 @@ func main() {
|
|||||||
transcodeSvc := service.NewTranscodeService(db, s3Svc)
|
transcodeSvc := service.NewTranscodeService(db, s3Svc)
|
||||||
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
||||||
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
||||||
|
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
||||||
|
|
||||||
// 9. 创建 Gin 引擎
|
// 9. 创建 Gin 引擎
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -112,6 +113,10 @@ func main() {
|
|||||||
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection)
|
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection)
|
||||||
handler.RegisterTrayBatchRoutes(api, db)
|
handler.RegisterTrayBatchRoutes(api, db)
|
||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
|
|
||||||
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
|
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
||||||
|
|
||||||
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
|
// 启动后台设备状态同步(每 30 秒查询 WVP 设备在线状态)
|
||||||
go startDeviceStatusSync(db, mediaSvc)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type Config struct {
|
|||||||
WechatSecret string `env:"WECHAT_SECRET" envDefault:""`
|
WechatSecret string `env:"WECHAT_SECRET" envDefault:""`
|
||||||
WechatTemplateAlarm string `env:"WECHAT_TEMPLATE_ALARM" envDefault:""`
|
WechatTemplateAlarm string `env:"WECHAT_TEMPLATE_ALARM" envDefault:""`
|
||||||
WechatTemplateInspection string `env:"WECHAT_TEMPLATE_INSPECTION" 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"`
|
InternalAPIKey string `env:"INTERNAL_API_KEY" envDefault:"silk-internal-2026"`
|
||||||
Port int `env:"PORT" envDefault:"3000"`
|
Port int `env:"PORT" envDefault:"3000"`
|
||||||
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
|
DefaultAdminUsername string `env:"DEFAULT_ADMIN_USERNAME" envDefault:"admin"`
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.InspectionRecord{},
|
&model.InspectionRecord{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
|
&model.WeatherAlert{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
slog.Warn("自动迁移有警告(可忽略)", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"rearing:write", "饲养记录管理", "新增、编辑、删除饲养记录"},
|
{"rearing:write", "饲养记录管理", "新增、编辑、删除饲养记录"},
|
||||||
{"notification:read", "订阅查看", "查看微信订阅绑定状态"},
|
{"notification:read", "订阅查看", "查看微信订阅绑定状态"},
|
||||||
{"notification:write", "订阅管理", "绑定微信并管理订阅授权"},
|
{"notification:write", "订阅管理", "绑定微信并管理订阅授权"},
|
||||||
|
{"weather:read", "天气查看", "查看天气与高发病天气预警"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"inspection:create", "inspection:read",
|
"inspection:create", "inspection:read",
|
||||||
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
|
"weather:read",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -58,6 +60,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"inspection:create", "inspection:read",
|
"inspection:create", "inspection:read",
|
||||||
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
"tray:read", "tray:write", "batch:read", "batch:write", "rearing:read", "rearing:write",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
|
"weather:read",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -66,6 +69,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"inspection:read",
|
"inspection:read",
|
||||||
"tray:read", "batch:read", "rearing:read",
|
"tray:read", "batch:read", "rearing:read",
|
||||||
"notification:read",
|
"notification:read",
|
||||||
|
"weather:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -74,5 +78,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"inspection:create", "inspection:read",
|
"inspection:create", "inspection:read",
|
||||||
"tray:read", "batch:read", "rearing:read",
|
"tray:read", "batch:read", "rearing:read",
|
||||||
"notification:read", "notification:write",
|
"notification:read", "notification:write",
|
||||||
|
"weather:read",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("缺少白僵病预警")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user