Compare commits

...

2 Commits

Author SHA1 Message Date
ac7c699530 feat:新增彩云天气预报 2025-08-22 16:58:09 +08:00
85cca73799 feat:新增下雨概率预测 2025-08-22 16:05:16 +08:00
8 changed files with 321 additions and 5 deletions

View File

@ -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 == "" {

View File

@ -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"
sslmode: "disable"
forecast:
caiyun_token: "ZAcZq49qzibr10F0"

View File

@ -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
View 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_hourlyprovider=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转换为 hPaPa/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
}

View File

@ -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)
}

View File

@ -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 {

View 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
}

View File

@ -453,6 +453,7 @@
<select id="forecastProvider">
<option value="">不显示预报</option>
<option value="open-meteo" selected>Open-Meteo</option>
<option value="caiyun">彩云</option>
</select>
</div>
@ -496,6 +497,7 @@
<th>风速 (m/s)</th>
<th>风向 (°)</th>
<th>雨量 (mm)</th>
<th>降水概率 (%)</th>
<th>光照 (lux)</th>
<th>紫外线</th>
</tr>
@ -1467,6 +1469,7 @@
<td>${item.wind_speed !== null && item.wind_speed !== undefined ? item.wind_speed.toFixed(2) : '-'}</td>
<td>${item.wind_direction !== null && item.wind_direction !== undefined ? item.wind_direction.toFixed(2) : '-'}</td>
<td>${item.rainfall !== null && item.rainfall !== undefined ? item.rainfall.toFixed(3) : '-'}</td>
<td>${item.source === '预报' && item.precip_prob !== null && item.precip_prob !== undefined ? item.precip_prob : '-'}</td>
<td>${item.light !== null && item.light !== undefined ? item.light.toFixed(2) : '-'}</td>
<td>${item.uv !== null && item.uv !== undefined ? item.uv.toFixed(2) : '-'}</td>
`;