220 lines
7.2 KiB
Go
220 lines
7.2 KiB
Go
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)
|
||
// 彩云小时接口返回的是“左端点”时刻(例如 13:00 表示 13:00-14:00 区间)。
|
||
// 我们将左端点列表保留为 startHour, startHour+1h, startHour+2h,并在写库时统一 +1h
|
||
// 使得 forecast_time 表示区间右端,与实测聚合对齐。
|
||
leftEdges := []time.Time{startHour, startHour.Add(1 * time.Hour), startHour.Add(2 * 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", leftEdges)
|
||
for _, left := range leftEdges {
|
||
v, ok := table[left]
|
||
if !ok {
|
||
log.Printf("时间点无数据: %s", left.Format(time.RFC3339))
|
||
continue
|
||
}
|
||
ft := left.Add(1 * time.Hour)
|
||
log.Printf("写入预报点: station=%s forecast_time=%s (source=%s) rain=%.3f temp=%.2f rh=%.1f ws=%.3f wdir=%.1f prob=%.1f pres=%.2f",
|
||
s.id, ft.Format(time.RFC3339), left.Format(time.RFC3339), v.rain, v.temp, v.rh, v.ws, v.wdir, v.prob, v.pres)
|
||
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
|
||
)
|
||
if 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))
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|