Compare commits

..

No commits in common. "ac7c6995307a5db59d70bb699b2db12525dc0d3d" and "4acb2b62ca1e6937eabea04ea0d245224dec6177" have entirely different histories.

8 changed files with 5 additions and 321 deletions

View File

@ -4,10 +4,8 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"sync" "sync"
"time" "time"
"weatherstation/internal/config"
"weatherstation/internal/database" "weatherstation/internal/database"
"weatherstation/internal/forecast" "weatherstation/internal/forecast"
"weatherstation/internal/selftest" "weatherstation/internal/selftest"
@ -30,8 +28,6 @@ func main() {
var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出") var selftestOnly = flag.Bool("selftest_only", false, "仅执行自检后退出")
// 预报抓取 // 预报抓取
var forecastOnly = flag.Bool("forecast_only", false, "仅执行一次open-meteo拉取并退出") 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() flag.Parse()
// 设置日志 // 设置日志
@ -61,32 +57,6 @@ func main() {
return 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 调试路径 // Backfill 调试路径
if *doBackfill { if *doBackfill {
if *bfFrom == "" || *bfTo == "" { if *bfFrom == "" || *bfTo == "" {

View File

@ -4,10 +4,7 @@ server:
database: database:
host: "8.134.185.53" host: "8.134.185.53"
port: 5432 port: 5432
user: "yarnom" user: "weatheruser"
password: "root" password: "yourpassword"
dbname: "weatherdb" dbname: "weatherdb"
sslmode: "disable" sslmode: "disable"
forecast:
caiyun_token: "ZAcZq49qzibr10F0"

View File

@ -23,15 +23,9 @@ type DatabaseConfig struct {
SSLMode string `yaml:"sslmode"` SSLMode string `yaml:"sslmode"`
} }
// ForecastConfig 预报相关配置
type ForecastConfig struct {
CaiyunToken string `yaml:"caiyun_token"`
}
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
Forecast ForecastConfig `yaml:"forecast"`
} }
var ( var (
@ -89,6 +83,5 @@ func (c *Config) validate() error {
if c.Database.SSLMode == "" { if c.Database.SSLMode == "" {
c.Database.SSLMode = "disable" // 默认禁用SSL c.Database.SSLMode = "disable" // 默认禁用SSL
} }
// CaiyunToken 允许为空:表示不启用彩云定时任务
return nil return nil
} }

View File

@ -1,202 +0,0 @@
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) points, err := database.GetForecastData(database.GetDB(), stationID, start, end, provider)
if err != nil { if err != nil {
log.Printf("查询预报数据失败: %v", err) log.Printf("查询预报数据失败: %v", err)
@ -191,6 +191,6 @@ func getForecastHandler(c *gin.Context) {
return return
} }
log.Printf("查询到预报数据: %d 条", len(points)) // log.Printf("查询到预报数据: %d 条", len(points))
c.JSON(http.StatusOK, points) c.JSON(http.StatusOK, points)
} }

View File

@ -159,25 +159,6 @@ 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 { for {
n, addr, err := conn.ReadFrom(buffer) n, addr, err := conn.ReadFrom(buffer)
if err != nil { if err != nil {

View File

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