570 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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