312 lines
8.4 KiB
Go
312 lines
8.4 KiB
Go
package server
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/binary"
|
||
"net/http"
|
||
"time"
|
||
"weatherstation/internal/database"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type rainTileRecord 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 rainTileResponse 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"` // 单位:mm,null 表示无效
|
||
}
|
||
|
||
func getLatestRainTile(db *sql.DB, z, y, x int) (*rainTileRecord, error) {
|
||
const q = `
|
||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||
FROM rain_tiles
|
||
WHERE z=$1 AND y=$2 AND x=$3
|
||
ORDER BY dt DESC
|
||
LIMIT 1`
|
||
var r rainTileRecord
|
||
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 getRainTileAt(db *sql.DB, z, y, x int, dt time.Time) (*rainTileRecord, error) {
|
||
const q = `
|
||
SELECT dt, z, y, x, width, height, west, south, east, north, res_deg, data
|
||
FROM rain_tiles
|
||
WHERE z=$1 AND y=$2 AND x=$3 AND dt=$4
|
||
LIMIT 1`
|
||
var r rainTileRecord
|
||
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
|
||
}
|
||
|
||
// latestRainTileHandler 返回指定 z/y/x 的最新一小时降雨瓦片
|
||
func latestRainTileHandler(c *gin.Context) {
|
||
z := parseIntDefault(c.Query("z"), 7)
|
||
y := parseIntDefault(c.Query("y"), 40)
|
||
x := parseIntDefault(c.Query("x"), 102)
|
||
|
||
rec, err := getLatestRainTile(database.GetDB(), z, y, x)
|
||
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 := decodeRain(rec.Data, w, h)
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
if loc == nil {
|
||
loc = time.FixedZone("CST", 8*3600)
|
||
}
|
||
resp := rainTileResponse{
|
||
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)
|
||
}
|
||
|
||
// rainTileAtHandler 返回指定 z/y/x 的指定时间(CST)瓦片
|
||
func rainTileAtHandler(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 := getRainTileAt(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 := decodeRain(rec.Data, w, h)
|
||
// 始终以 CST(+8) 输出
|
||
resp := rainTileResponse{
|
||
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)
|
||
}
|
||
|
||
// rainTileTimesHandler 返回指定 z/y/x 的可用时间列表(倒序)
|
||
func rainTileTimesHandler(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 rain_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 rain_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})
|
||
}
|
||
|
||
// nearestRainTileHandler 返回最接近给定时间的瓦片
|
||
func nearestRainTileHandler(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"), 90)
|
||
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
|
||
}
|
||
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 rain_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 rain_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 := getRainTileAt(db, z, y, x, picked)
|
||
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 := decodeRain(rec.Data, w, h)
|
||
// 以 CST(+8) 输出
|
||
resp := rainTileResponse{
|
||
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 decodeRain(buf []byte, w, h int) [][]*float64 {
|
||
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(buf[off : off+2]))
|
||
off += 2
|
||
if v >= 32766 { // 无效
|
||
rowVals[col] = nil
|
||
continue
|
||
}
|
||
mm := float64(v) / 10.0 // 0.1 mm 精度
|
||
if mm < 0 {
|
||
mm = 0
|
||
}
|
||
vv := mm
|
||
rowVals[col] = &vv
|
||
}
|
||
vals[row] = rowVals
|
||
}
|
||
return vals
|
||
}
|