feat:新增彩云天气预报
This commit is contained in:
parent
85cca73799
commit
ac7c699530
@ -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 == "" {
|
||||
|
||||
@ -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"
|
||||
|
||||
forecast:
|
||||
caiyun_token: "ZAcZq49qzibr10F0"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
202
internal/forecast/caiyun.go
Normal file
202
internal/forecast/caiyun.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
52
internal/tools/forecast_fetch.go
Normal file
52
internal/tools/forecast_fetch.go
Normal file
@ -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
|
||||
}
|
||||
@ -453,6 +453,7 @@
|
||||
<select id="forecastProvider">
|
||||
<option value="">不显示预报</option>
|
||||
<option value="open-meteo" selected>Open-Meteo</option>
|
||||
<option value="caiyun">彩云</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user