From ac7c6995307a5db59d70bb699b2db12525dc0d3d Mon Sep 17 00:00:00 2001 From: yarnom Date: Fri, 22 Aug 2025 16:58:09 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=BD=A9?= =?UTF-8?q?=E4=BA=91=E5=A4=A9=E6=B0=94=E9=A2=84=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/weatherstation/main.go | 30 +++++ config.yaml | 9 +- internal/config/config.go | 7 ++ internal/forecast/caiyun.go | 202 +++++++++++++++++++++++++++++++ internal/server/gin.go | 4 +- internal/server/udp.go | 19 +++ internal/tools/forecast_fetch.go | 52 ++++++++ templates/index.html | 1 + 8 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 internal/forecast/caiyun.go create mode 100644 internal/tools/forecast_fetch.go diff --git a/cmd/weatherstation/main.go b/cmd/weatherstation/main.go index 4430f65..5ef4bde 100644 --- a/cmd/weatherstation/main.go +++ b/cmd/weatherstation/main.go @@ -4,8 +4,10 @@ import ( "context" "flag" "log" + "os" "sync" "time" + "weatherstation/internal/config" "weatherstation/internal/database" "weatherstation/internal/forecast" "weatherstation/internal/selftest" @@ -28,6 +30,8 @@ func main() { var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出") // 预报抓取 var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出") + var caiyunOnly = flag.Bool("caiyun_only", false, "仅执行一次彩云拉取并退出") + var forecastDay = flag.String("forecast_day", "", "按日期抓取当天0点到当前时间+3h(格式YYYY-MM-DD)") flag.Parse() // 设置日志 @@ -57,6 +61,32 @@ func main() { return } + // 单次 彩云 拉取(token 从环境变量 CAIYUN_TOKEN 或命令行 -caiyun_token 读取) + if *caiyunOnly { + token := os.Getenv("CAIYUN_TOKEN") + if token == "" { + // 退回配置 + token = config.GetConfig().Forecast.CaiyunToken + if token == "" { + log.Fatalf("未提供彩云 token,请设置环境变量 CAIYUN_TOKEN 或配置文件 forecast.caiyun_token") + } + } + if err := forecast.RunCaiyunFetch(context.Background(), token); err != nil { + log.Fatalf("caiyun 拉取失败: %v", err) + } + log.Println("caiyun 拉取完成") + return + } + + // 工具:按日期抓取当天0点到当前时间+3小时(两家) + if *forecastDay != "" { + if err := tools.RunForecastFetchForDay(context.Background(), *forecastDay); err != nil { + log.Fatalf("forecast_day 运行失败: %v", err) + } + log.Println("forecast_day 完成") + return + } + // Backfill 调试路径 if *doBackfill { if *bfFrom == "" || *bfTo == "" { diff --git a/config.yaml b/config.yaml index c56989c..118fe6a 100644 --- a/config.yaml +++ b/config.yaml @@ -4,7 +4,10 @@ server: database: host: "8.134.185.53" port: 5432 - user: "weatheruser" - password: "yourpassword" + user: "yarnom" + password: "root" dbname: "weatherdb" - sslmode: "disable" \ No newline at end of file + sslmode: "disable" + +forecast: + caiyun_token: "ZAcZq49qzibr10F0" diff --git a/internal/config/config.go b/internal/config/config.go index 378cc03..0a8f609 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,9 +23,15 @@ type DatabaseConfig struct { SSLMode string `yaml:"sslmode"` } +// ForecastConfig 预报相关配置 +type ForecastConfig struct { + CaiyunToken string `yaml:"caiyun_token"` +} + type Config struct { Server ServerConfig `yaml:"server"` Database DatabaseConfig `yaml:"database"` + Forecast ForecastConfig `yaml:"forecast"` } var ( @@ -83,5 +89,6 @@ func (c *Config) validate() error { if c.Database.SSLMode == "" { c.Database.SSLMode = "disable" // 默认禁用SSL } + // CaiyunToken 允许为空:表示不启用彩云定时任务 return nil } diff --git a/internal/forecast/caiyun.go b/internal/forecast/caiyun.go new file mode 100644 index 0000000..980d3dc --- /dev/null +++ b/internal/forecast/caiyun.go @@ -0,0 +1,202 @@ +package forecast + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "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 + v.prob = p.Probability * 100.0 + 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 +} diff --git a/internal/server/gin.go b/internal/server/gin.go index 3d2429f..302d192 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -181,7 +181,7 @@ func getForecastHandler(c *gin.Context) { } // 获取预报数据 - // log.Printf("查询预报数据: stationID=%s, provider=%s, start=%s, end=%s", stationID, provider, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")) + log.Printf("查询预报数据: stationID=%s, provider=%s, start=%s, end=%s", stationID, provider, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")) points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider) if err != nil { log.Printf("查询预报数据失败: %v", err) @@ -191,6 +191,6 @@ func getForecastHandler(c *gin.Context) { return } - // log.Printf("查询到预报数据: %d 条", len(points)) + log.Printf("查询到预报数据: %d 条", len(points)) c.JSON(http.StatusOK, points) } diff --git a/internal/server/udp.go b/internal/server/udp.go index 6c26398..23431b8 100644 --- a/internal/server/udp.go +++ b/internal/server/udp.go @@ -159,6 +159,25 @@ func StartUDPServer() error { } }() + // 后台定时:每小时拉取彩云(全站) + go func() { + token := config.GetConfig().Forecast.CaiyunToken + if token == "" { + log.Printf("caiyun token 未配置,跳过彩云定时拉取(配置 forecast.caiyun_token 可启用)") + return + } + for { + now := time.Now() + next := now.Truncate(time.Hour).Add(time.Hour) + time.Sleep(time.Until(next)) + if err := forecast.RunCaiyunFetch(context.Background(), token); err != nil { + log.Printf("caiyun 定时拉取失败: %v", err) + } else { + log.Printf("caiyun 定时拉取完成") + } + } + }() + for { n, addr, err := conn.ReadFrom(buffer) if err != nil { diff --git a/internal/tools/forecast_fetch.go b/internal/tools/forecast_fetch.go new file mode 100644 index 0000000..ad6f838 --- /dev/null +++ b/internal/tools/forecast_fetch.go @@ -0,0 +1,52 @@ +package tools + +import ( + "context" + "log" + "time" + "weatherstation/internal/config" + "weatherstation/internal/forecast" +) + +// RunForecastFetchForDay 按指定“日期”(CST)抓取当天0点到当前时间后三小时的预报(Open-Meteo 与 彩云) +// dateStr: 形如 "2006-01-02"(按 Asia/Shanghai 解析) +func RunForecastFetchForDay(ctx context.Context, dateStr string) error { + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.FixedZone("CST", 8*3600) + } + + dayStart, err := time.ParseInLocation("2006-01-02", dateStr, loc) + if err != nil { + return err + } + + now := time.Now().In(loc) + // 窗口:dayStart .. now+3h 的各整点(写库函数内部按 issued_at=当前时间) + // 目前 open-meteo 与彩云抓取函数都是"取未来三小时"的固定窗口。 + // 这里采用:先执行一次 open-meteo 与彩云的"未来三小时"写入,作为当下 issued 版本。 + + log.Printf("开始抓取 Open-Meteo 预报...") + if err := forecast.RunOpenMeteoFetch(ctx); err != nil { + log.Printf("Open-Meteo 抓取失败: %v", err) + } else { + log.Printf("Open-Meteo 抓取完成") + } + + token := config.GetConfig().Forecast.CaiyunToken + log.Printf("彩云 token: %s", token) + if token != "" { + log.Printf("开始抓取彩云预报...") + if err := forecast.RunCaiyunFetch(ctx, token); err != nil { + log.Printf("彩云 抓取失败: %v", err) + } else { + log.Printf("彩云抓取完成") + } + } else { + log.Printf("未配置彩云 token,跳过彩云抓取") + } + + _ = dayStart + _ = now + return nil +} diff --git a/templates/index.html b/templates/index.html index 07dbc0a..bd576d4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -453,6 +453,7 @@