package forecast import ( "context" "database/sql" "encoding/json" "fmt" "io" "log" "math" "net/http" "time" "weatherstation/internal/database" ) // 彩云返回结构(仅取用需要的字段) type caiyunHourly struct { Status string `json:"status"` Result struct { Hourly struct { Status string `json:"status"` Temperature []struct { Datetime string `json:"datetime"` Value float64 `json:"value"` } `json:"temperature"` Humidity []struct { Datetime string `json:"datetime"` Value float64 `json:"value"` } `json:"humidity"` Wind []struct { Datetime string `json:"datetime"` Speed float64 `json:"speed"` Direction float64 `json:"direction"` } `json:"wind"` Precipitation []struct { Datetime string `json:"datetime"` Value float64 `json:"value"` Probability float64 `json:"probability"` } `json:"precipitation"` Pressure []struct { Datetime string `json:"datetime"` Value float64 `json:"value"` } `json:"pressure"` } `json:"hourly"` } `json:"result"` } // RunCaiyunFetch 拉取各站点未来三小时并写入 forecast_hourly(provider=caiyun) func RunCaiyunFetch(ctx context.Context, token string) error { log.Printf("彩云抓取开始,token=%s", token) db := database.GetDB() stations, err := loadStationsWithLatLon(ctx, db) if err != nil { log.Printf("加载站点失败: %v", err) return err } log.Printf("找到 %d 个有经纬度的站点", len(stations)) client := &http.Client{Timeout: 15 * time.Second} loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } issuedAt := time.Now().In(loc) startHour := issuedAt.Truncate(time.Hour) targets := []time.Time{startHour.Add(1 * time.Hour), startHour.Add(2 * time.Hour), startHour.Add(3 * time.Hour)} for _, s := range stations { if !s.lat.Valid || !s.lon.Valid { continue } url := fmt.Sprintf("https://api.caiyunapp.com/v2.6/%s/%f,%f/hourly?hourlysteps=4&unit=metric:v2", token, s.lon.Float64, s.lat.Float64) log.Printf("请求彩云 API: %s", url) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := client.Do(req) if err != nil { log.Printf("caiyun 请求失败 station=%s err=%v", s.id, err) continue } log.Printf("彩云响应状态码: %d", resp.StatusCode) var data caiyunHourly body, _ := io.ReadAll(resp.Body) log.Printf("彩云响应内容: %s", string(body)) resp.Body.Close() if err := json.Unmarshal(body, &data); err != nil { log.Printf("caiyun 解码失败 station=%s err=%v", s.id, err) continue } log.Printf("彩云响应解析: status=%s", data.Status) // 彩云时间戳形式例如 2022-05-26T16:00+08:00,需按CST解析 // 建立 time->vals 映射 table := map[time.Time]struct { rain float64 temp float64 rh float64 ws float64 wdir float64 prob float64 pres float64 }{} // 温度 ℃ for _, t := range data.Result.Hourly.Temperature { log.Printf("解析时间: %s", t.Datetime) if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", t.Datetime, loc); err == nil { log.Printf("解析结果: %v", ft) v := table[ft] v.temp = t.Value table[ft] = v } else { log.Printf("时间解析失败: %v", err) } } // 湿度 比例(0..1) 转换为 % for _, h := range data.Result.Hourly.Humidity { if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", h.Datetime, loc); err == nil { v := table[ft] v.rh = h.Value * 100.0 table[ft] = v } } // 风:metric:v2速度为km/h,这里转换为m/s;方向为度 for _, w := range data.Result.Hourly.Wind { if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", w.Datetime, loc); err == nil { v := table[ft] v.ws = w.Speed / 3.6 v.wdir = w.Direction table[ft] = v } } // 降水 该小时量 mm,概率 0..1 → % for _, p := range data.Result.Hourly.Precipitation { if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", p.Datetime, loc); err == nil { v := table[ft] v.rain = p.Value // 直接使用API返回的概率值,只进行范围限制 prob := p.Probability // 四舍五入并确保在0-100范围内 prob = math.Round(prob) if prob < 0 { prob = 0 } if prob > 100 { prob = 100 } v.prob = prob table[ft] = v } } // 气压:单位为 Pa,转换为 hPa(Pa/100) for _, pr := range data.Result.Hourly.Pressure { if ft, err := time.ParseInLocation("2006-01-02T15:04-07:00", pr.Datetime, loc); err == nil { v := table[ft] v.pres = pr.Value / 100.0 table[ft] = v } } log.Printf("处理时间点: %v", targets) for _, ft := range targets { if v, ok := table[ft]; ok { log.Printf("写入预报点: station=%s time=%s rain=%.3f temp=%.2f rh=%.1f ws=%.3f wdir=%.1f prob=%.1f pres=%.2f", s.id, ft.Format(time.RFC3339), v.rain, v.temp, v.rh, v.ws, v.wdir, v.prob, v.pres) if err := upsertForecastCaiyun(ctx, db, s.id, issuedAt, ft, int64(v.rain*1000.0), // mm → x1000 int64(v.temp*100.0), // °C → x100 int64(v.rh), // % int64(v.ws*1000.0), // m/s → x1000 int64(0), // gust: 彩云小时接口无阵风,置0 int64(v.wdir), // 度 int64(v.prob), // % int64(v.pres*100.0), // hPa → x100 ); err != nil { log.Printf("写入forecast失败(caiyun) station=%s time=%s err=%v", s.id, ft.Format(time.RFC3339), err) } else { log.Printf("写入forecast成功(caiyun) station=%s time=%s", s.id, ft.Format(time.RFC3339)) } } else { log.Printf("时间点无数据: %s", ft.Format(time.RFC3339)) } } } return nil } func upsertForecastCaiyun(ctx context.Context, db *sql.DB, stationID string, issuedAt, forecastTime time.Time, rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100 int64, ) error { _, err := db.ExecContext(ctx, ` INSERT INTO forecast_hourly ( station_id, provider, issued_at, forecast_time, rain_mm_x1000, temp_c_x100, humidity_pct, wind_speed_ms_x1000, wind_gust_ms_x1000, wind_dir_deg, precip_prob_pct, pressure_hpa_x100 ) VALUES ($1, 'caiyun', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (station_id, provider, issued_at, forecast_time) DO UPDATE SET rain_mm_x1000 = EXCLUDED.rain_mm_x1000, temp_c_x100 = EXCLUDED.temp_c_x100, humidity_pct = EXCLUDED.humidity_pct, wind_speed_ms_x1000 = EXCLUDED.wind_speed_ms_x1000, wind_gust_ms_x1000 = EXCLUDED.wind_gust_ms_x1000, wind_dir_deg = EXCLUDED.wind_dir_deg, precip_prob_pct = EXCLUDED.precip_prob_pct, pressure_hpa_x100 = EXCLUDED.pressure_hpa_x100 `, stationID, issuedAt, forecastTime, rainMmX1000, tempCx100, humidityPct, wsMsX1000, gustMsX1000, wdirDeg, probPct, pressureHpaX100) return err }