package server import ( "database/sql" "encoding/binary" "fmt" "math" "net/http" "time" "weatherstation/internal/config" "weatherstation/internal/database" "github.com/gin-gonic/gin" ) type radarTileRecord struct { DT time.Time Z int Y int X int Width int Height int West float64 South float64 East float64 North float64 ResDeg float64 Data []byte } type radarTileResponse struct { DT string `json:"dt"` Z int `json:"z"` Y int `json:"y"` X int `json:"x"` Width int `json:"width"` Height int `json:"height"` West float64 `json:"west"` South float64 `json:"south"` East float64 `json:"east"` North float64 `json:"north"` ResDeg float64 `json:"res_deg"` Values [][]*float64 `json:"values"` // null 表示无效值 } func getLatestRadarTile(db *sql.DB, z, y, x int) (*radarTileRecord, error) { const q = ` SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 ORDER BY dt DESC LIMIT 1` var r radarTileRecord err := db.QueryRow(q, z, y, x).Scan( &r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data, ) if err != nil { return nil, err } return &r, nil } func getRadarTileAt(db *sql.DB, z, y, x int, dt time.Time) (*radarTileRecord, error) { const q = ` SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4 LIMIT 1` var r radarTileRecord err := db.QueryRow(q, z, y, x, dt).Scan( &r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data, ) if err != nil { return nil, err } return &r, nil } // latestRadarTileHandler 返回指定 z/y/x 的最新瓦片,包含栅格 dBZ 值及元数据 func latestRadarTileHandler(c *gin.Context) { // 固定默认 7/40/102,可通过查询参数覆盖 z := parseIntDefault(c.Query("z"), 7) y := parseIntDefault(c.Query("y"), 40) x := parseIntDefault(c.Query("x"), 102) rec, err := getLatestRadarTile(database.GetDB(), z, y, x) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到雷达瓦片"}) return } // 解码大端 int16 → dBZ (raw/10). >=32766 视为无效(null) w, h := rec.Width, rec.Height vals := make([][]*float64, h) // 每行 256 单元,每单元 2 字节 if len(rec.Data) < w*h*2 { c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) return } off := 0 for row := 0; row < h; row++ { rowVals := make([]*float64, w) for col := 0; col < w; col++ { v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2])) off += 2 if v >= 32766 { rowVals[col] = nil continue } dbz := float64(v) / 10.0 // 限幅到 [0,75](大部分 CREF 标准范围),便于颜色映射 if dbz < 0 { dbz = 0 } else if dbz > 75 { dbz = 75 } vv := dbz rowVals[col] = &vv } vals[row] = rowVals } loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } resp := radarTileResponse{ DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, Width: rec.Width, Height: rec.Height, West: rec.West, South: rec.South, East: rec.East, North: rec.North, ResDeg: rec.ResDeg, Values: vals, } c.JSON(http.StatusOK, resp) } // radarTileAtHandler 返回指定 z/y/x 的指定时间瓦片 func radarTileAtHandler(c *gin.Context) { z := parseIntDefault(c.Query("z"), 7) y := parseIntDefault(c.Query("y"), 40) x := parseIntDefault(c.Query("x"), 102) dtStr := c.Query("dt") if dtStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) return } loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) return } rec, err := getRadarTileAt(database.GetDB(), z, y, x, dt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间瓦片"}) return } w, h := rec.Width, rec.Height if len(rec.Data) < w*h*2 { c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) return } vals := make([][]*float64, h) off := 0 for row := 0; row < h; row++ { rowVals := make([]*float64, w) for col := 0; col < w; col++ { v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2])) off += 2 if v >= 32766 { rowVals[col] = nil continue } dbz := float64(v) / 10.0 if dbz < 0 { dbz = 0 } else if dbz > 75 { dbz = 75 } vv := dbz rowVals[col] = &vv } vals[row] = rowVals } // 统一以 CST(+8) 输出 resp := radarTileResponse{ DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, Width: rec.Width, Height: rec.Height, West: rec.West, South: rec.South, East: rec.East, North: rec.North, ResDeg: rec.ResDeg, Values: vals, } c.JSON(http.StatusOK, resp) } // radarTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序) func radarTileTimesHandler(c *gin.Context) { z := parseIntDefault(c.Query("z"), 7) y := parseIntDefault(c.Query("y"), 40) x := parseIntDefault(c.Query("x"), 102) fromStr := c.Query("from") toStr := c.Query("to") loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } var rows *sql.Rows var err error if fromStr != "" && toStr != "" { from, err1 := time.ParseInLocation("2006-01-02 15:04:05", fromStr, loc) to, err2 := time.ParseInLocation("2006-01-02 15:04:05", toStr, loc) if err1 != nil || err2 != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 from/to 时间格式"}) return } const qRange = ` SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 ORDER BY dt DESC` rows, err = database.GetDB().Query(qRange, z, y, x, from, to) } else { limit := parseIntDefault(c.Query("limit"), 48) const q = ` SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 ORDER BY dt DESC LIMIT $4` rows, err = database.GetDB().Query(q, z, y, x, limit) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "查询时间列表失败"}) return } defer rows.Close() var times []string for rows.Next() { var dt time.Time if err := rows.Scan(&dt); err != nil { continue } times = append(times, dt.In(loc).Format("2006-01-02 15:04:05")) } c.JSON(http.StatusOK, gin.H{"times": times}) } // radarTilesAtHandler returns all radar tiles at the given dt for a specific z. // It aggregates rows with the same z and dt but different y/x so the frontend can overlay them together. // GET /api/radar/tiles_at?z=7&dt=YYYY-MM-DD HH:MM:SS func radarTilesAtHandler(c *gin.Context) { z := parseIntDefault(c.Query("z"), 7) dtStr := c.Query("dt") if dtStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) return } loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) return } // Query all tiles at z + dt const q = ` SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data FROM radar_tiles WHERE z=$1 AND dt=$2 ORDER BY y, x` rows, qerr := database.GetDB().Query(q, z, dt) if qerr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("数据库查询失败: %v", qerr)}) return } defer rows.Close() var tiles []radarTileResponse for rows.Next() { var r radarTileRecord if err := rows.Scan(&r.DT, &r.Z, &r.Y, &r.X, &r.Width, &r.Height, &r.West, &r.South, &r.East, &r.North, &r.ResDeg, &r.Data); err != nil { continue } w, h := r.Width, r.Height if w <= 0 || h <= 0 || len(r.Data) < w*h*2 { continue } vals := make([][]*float64, h) off := 0 for row := 0; row < h; row++ { rowVals := make([]*float64, w) for col := 0; col < w; col++ { v := int16(binary.BigEndian.Uint16(r.Data[off : off+2])) off += 2 if v >= 32766 { rowVals[col] = nil continue } dbz := float64(v) / 10.0 if dbz < 0 { dbz = 0 } else if dbz > 75 { dbz = 75 } vv := dbz rowVals[col] = &vv } vals[row] = rowVals } tiles = append(tiles, radarTileResponse{ DT: r.DT.In(loc).Format("2006-01-02 15:04:05"), Z: r.Z, Y: r.Y, X: r.X, Width: r.Width, Height: r.Height, West: r.West, South: r.South, East: r.East, North: r.North, ResDeg: r.ResDeg, Values: vals, }) } if len(tiles) == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的雷达瓦片集合"}) return } c.JSON(http.StatusOK, gin.H{ "dt": dt.In(loc).Format("2006-01-02 15:04:05"), "tiles": tiles, }) } // nearestRadarTileHandler 返回最接近给定时间的瓦片(支持 z/y/x、容差分钟、偏好 lte 或 nearest) func nearestRadarTileHandler(c *gin.Context) { z := parseIntDefault(c.Query("z"), 7) y := parseIntDefault(c.Query("y"), 40) x := parseIntDefault(c.Query("x"), 102) dtStr := c.Query("dt") if dtStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) return } tolMin := parseIntDefault(c.Query("tolerance_min"), 30) prefer := c.DefaultQuery("prefer", "nearest") // nearest|lte loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } target, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) return } from := target.Add(-time.Duration(tolMin) * time.Minute) to := target.Add(time.Duration(tolMin) * time.Minute) db := database.GetDB() var row *sql.Row if prefer == "lte" { const q = ` SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 AND dt <= $6 ORDER BY ($6 - dt) ASC LIMIT 1` row = db.QueryRow(q, z, y, x, from, to, target) } else { const q = ` SELECT dt FROM radar_tiles WHERE z=$1 AND y=$2 AND x=$3 AND dt BETWEEN $4 AND $5 ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $6))) ASC LIMIT 1` row = db.QueryRow(q, z, y, x, from, to, target) } var picked time.Time if err := row.Scan(&picked); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "在容差范围内未找到匹配瓦片"}) return } rec, err := getRadarTileAt(db, z, y, x, picked) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到匹配瓦片数据"}) return } // 解码与 latest/at 相同 w, h := rec.Width, rec.Height if len(rec.Data) < w*h*2 { c.JSON(http.StatusInternalServerError, gin.H{"error": "数据长度异常"}) return } vals := make([][]*float64, h) off := 0 for rowi := 0; rowi < h; rowi++ { rowVals := make([]*float64, w) for col := 0; col < w; col++ { v := int16(binary.BigEndian.Uint16(rec.Data[off : off+2])) off += 2 if v >= 32766 { rowVals[col] = nil continue } dbz := float64(v) / 10.0 if dbz < 0 { dbz = 0 } else if dbz > 75 { dbz = 75 } vv := dbz rowVals[col] = &vv } vals[rowi] = rowVals } // 统一以 CST(+8) 输出 resp := radarTileResponse{ DT: rec.DT.In(loc).Format("2006-01-02 15:04:05"), Z: rec.Z, Y: rec.Y, X: rec.X, Width: rec.Width, Height: rec.Height, West: rec.West, South: rec.South, East: rec.East, North: rec.North, ResDeg: rec.ResDeg, Values: vals, } c.JSON(http.StatusOK, resp) } func parseIntDefault(s string, def int) int { if s == "" { return def } var n int _, err := fmtSscanf(s, &n) if err != nil || n == 0 || n == math.MinInt || n == math.MaxInt { return def } return n } // fmtSscanf is a tiny wrapper to avoid importing fmt only for Sscanf func fmtSscanf(s string, n *int) (int, error) { // naive fast parse sign := 1 i := 0 if len(s) > 0 && (s[0] == '-' || s[0] == '+') { if s[0] == '-' { sign = -1 } i = 1 } val := 0 for ; i < len(s); i++ { ch := s[i] if ch < '0' || ch > '9' { return 0, fmtError("invalid") } val = val*10 + int(ch-'0') } *n = sign * val return 1, nil } type fmtError string func (e fmtError) Error() string { return string(e) } // ---------------- Radar station realtime API ---------------- type radarWeatherRecord struct { Alias string Lat float64 Lon float64 DT time.Time Temperature float64 Humidity float64 Cloudrate float64 Visibility float64 Dswrf float64 WindSpeed float64 WindDirection float64 Pressure float64 } type radarWeatherResponse struct { Alias string `json:"alias"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` DT string `json:"dt"` Temperature float64 `json:"temperature"` Humidity float64 `json:"humidity"` Cloudrate float64 `json:"cloudrate"` Visibility float64 `json:"visibility"` Dswrf float64 `json:"dswrf"` WindSpeed float64 `json:"wind_speed"` WindDirection float64 `json:"wind_direction"` Pressure float64 `json:"pressure"` } func latestRadarWeatherHandler(c *gin.Context) { alias := c.Query("alias") if alias == "" { alias = "南宁雷达站" } const q = ` SELECT alias, lat, lon, dt, temperature, humidity, cloudrate, visibility, dswrf, wind_speed, wind_direction, pressure FROM radar_weather WHERE alias = $1 ORDER BY dt DESC LIMIT 1` var r radarWeatherRecord err := database.GetDB().QueryRow(q, alias).Scan( &r.Alias, &r.Lat, &r.Lon, &r.DT, &r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf, &r.WindSpeed, &r.WindDirection, &r.Pressure, ) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到实时气象数据"}) return } resp := radarWeatherResponse{ Alias: r.Alias, Lat: r.Lat, Lon: r.Lon, DT: r.DT.Format("2006-01-02 15:04:05"), Temperature: r.Temperature, Humidity: r.Humidity, Cloudrate: r.Cloudrate, Visibility: r.Visibility, Dswrf: r.Dswrf, WindSpeed: r.WindSpeed, WindDirection: r.WindDirection, Pressure: r.Pressure, } c.JSON(http.StatusOK, resp) } // radarWeatherAtHandler returns the radar weather record at the given dt (CST), // rounded/exact 10-minute bucket time string "YYYY-MM-DD HH:MM:SS". func radarWeatherAtHandler(c *gin.Context) { alias := c.Query("alias") if alias == "" { alias = "南宁雷达站" } dtStr := c.Query("dt") if dtStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) return } loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } dt, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) return } const q = ` SELECT alias, lat, lon, dt, temperature, humidity, cloudrate, visibility, dswrf, wind_speed, wind_direction, pressure FROM radar_weather WHERE alias = $1 AND dt = $2 LIMIT 1` var r radarWeatherRecord err = database.GetDB().QueryRow(q, alias, dt).Scan( &r.Alias, &r.Lat, &r.Lon, &r.DT, &r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf, &r.WindSpeed, &r.WindDirection, &r.Pressure, ) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到指定时间的实时气象数据"}) return } resp := radarWeatherResponse{ Alias: r.Alias, Lat: r.Lat, Lon: r.Lon, DT: r.DT.Format("2006-01-02 15:04:05"), Temperature: r.Temperature, Humidity: r.Humidity, Cloudrate: r.Cloudrate, Visibility: r.Visibility, Dswrf: r.Dswrf, WindSpeed: r.WindSpeed, WindDirection: r.WindDirection, Pressure: r.Pressure, } c.JSON(http.StatusOK, resp) } // radarWeatherNearestHandler returns the nearest radar_weather record to the given dt. // prefer=lte will pick the latest record not later than dt; else chooses absolute nearest. func radarWeatherNearestHandler(c *gin.Context) { alias := c.Query("alias") if alias == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 alias 参数"}) return } dtStr := c.Query("dt") if dtStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 dt 参数(YYYY-MM-DD HH:MM:SS)"}) return } prefer := c.DefaultQuery("prefer", "lte") // lte|nearest loc, _ := time.LoadLocation("Asia/Shanghai") if loc == nil { loc = time.FixedZone("CST", 8*3600) } target, err := time.ParseInLocation("2006-01-02 15:04:05", dtStr, loc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 dt 格式"}) return } var row *sql.Row db := database.GetDB() if prefer == "nearest" { const q = ` SELECT alias, lat, lon, dt, temperature, humidity, cloudrate, visibility, dswrf, wind_speed, wind_direction, pressure FROM radar_weather WHERE alias = $1 ORDER BY ABS(EXTRACT(EPOCH FROM (dt - $2))) ASC LIMIT 1` row = db.QueryRow(q, alias, target) } else { // lte default const q = ` SELECT alias, lat, lon, dt, temperature, humidity, cloudrate, visibility, dswrf, wind_speed, wind_direction, pressure FROM radar_weather WHERE alias = $1 AND dt <= $2 ORDER BY dt DESC LIMIT 1` row = db.QueryRow(q, alias, target) } var r radarWeatherRecord if err := row.Scan( &r.Alias, &r.Lat, &r.Lon, &r.DT, &r.Temperature, &r.Humidity, &r.Cloudrate, &r.Visibility, &r.Dswrf, &r.WindSpeed, &r.WindDirection, &r.Pressure, ); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "未找到就近的实时气象数据"}) return } ageMin := int(target.Sub(r.DT).Minutes()) if ageMin < 0 { // for nearest mode, could be future relative to target ageMin = -ageMin } resp := gin.H{ "alias": r.Alias, "lat": r.Lat, "lon": r.Lon, "dt": r.DT.Format("2006-01-02 15:04:05"), "temperature": r.Temperature, "humidity": r.Humidity, "cloudrate": r.Cloudrate, "visibility": r.Visibility, "dswrf": r.Dswrf, "wind_speed": r.WindSpeed, "wind_direction": r.WindDirection, "pressure": r.Pressure, "age_minutes": ageMin, "stale": ageMin > 120, } c.JSON(http.StatusOK, resp) } // radarWeatherAliasesHandler 返回 radar_weather 中存在的站点别名及经纬度(按最近记录去重) func radarWeatherAliasesHandler(c *gin.Context) { const q = ` SELECT DISTINCT ON (alias) alias, lat, lon, dt FROM radar_weather ORDER BY alias, dt DESC` rows, err := database.GetDB().Query(q) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "查询别名失败"}) return } defer rows.Close() type item struct { Alias string `json:"alias"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } var list []item for rows.Next() { var a string var lat, lon float64 var dt time.Time if err := rows.Scan(&a, &lat, &lon, &dt); err != nil { continue } list = append(list, item{Alias: a, Lat: lat, Lon: lon}) } c.JSON(http.StatusOK, gin.H{"aliases": list}) } // radarConfigAliasesHandler 返回配置文件中的雷达别名列表(含 z/y/x 和经纬度) func radarConfigAliasesHandler(c *gin.Context) { cfg := config.GetConfig() type item struct { Alias string `json:"alias"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` Z int `json:"z"` Y int `json:"y"` X int `json:"x"` } out := make([]item, 0, len(cfg.Radar.Aliases)) for _, a := range cfg.Radar.Aliases { out = append(out, item{Alias: a.Alias, Lat: a.Lat, Lon: a.Lon, Z: a.Z, Y: a.Y, X: a.X}) } c.JSON(http.StatusOK, gin.H{"aliases": out}) }