feat(server-go): 和风天气接入与高发病天气预警规则(#12)

This commit is contained in:
weijuesen
2026-08-12 17:40:24 +08:00
parent fa86542e55
commit c7f18c1381
10 changed files with 464 additions and 0 deletions
+133
View File
@@ -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 应返回错误")
}
}