570 lines
15 KiB
Go
570 lines
15 KiB
Go
package server
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/binary"
|
||
"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
|
||
}
|
||
|
||
resp := radarTileResponse{
|
||
DT: rec.DT.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
|
||
}
|
||
resp := radarTileResponse{
|
||
DT: rec.DT.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.Format("2006-01-02 15:04:05"))
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"times": times})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
resp := radarTileResponse{
|
||
DT: rec.DT.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)
|
||
}
|
||
|
||
// 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})
|
||
}
|