517 lines
14 KiB
Go
517 lines
14 KiB
Go
package server
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"strconv"
|
||
"time"
|
||
"weatherstation/internal/config"
|
||
"weatherstation/internal/database"
|
||
rf "weatherstation/internal/radarfetch"
|
||
"weatherstation/pkg/types"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"math"
|
||
)
|
||
|
||
// StartGinServer 启动Gin Web服务器
|
||
func StartGinServer() error {
|
||
// 设置Gin模式
|
||
gin.SetMode(gin.ReleaseMode)
|
||
|
||
// 创建Gin引擎
|
||
r := gin.Default()
|
||
|
||
// 加载HTML模板
|
||
r.LoadHTMLGlob("templates/*")
|
||
|
||
// 静态文件服务
|
||
r.Static("/static", "./static")
|
||
// 雷达数据静态目录(用于访问 latest 下的图片/二进制)
|
||
r.Static("/radar", "./radar_data")
|
||
|
||
// 路由设置
|
||
r.GET("/", indexHandler)
|
||
|
||
// API路由组
|
||
api := r.Group("/api")
|
||
{
|
||
api.GET("/system/status", systemStatusHandler)
|
||
api.GET("/stations", getStationsHandler)
|
||
api.GET("/data", getDataHandler)
|
||
api.GET("/forecast", getForecastHandler)
|
||
api.GET("/radar/latest", radarLatestHandler)
|
||
api.GET("/radar/latest/grid", radarLatestGridHandler)
|
||
api.GET("/radar/latest/wind", radarLatestWindHandler)
|
||
}
|
||
|
||
// 获取配置的Web端口
|
||
port := config.GetConfig().Server.WebPort
|
||
if port == 0 {
|
||
port = 10003 // 默认端口
|
||
}
|
||
|
||
// 备注:雷达抓取改为独立 CLI 触发,Web 服务不自动启动后台任务
|
||
|
||
// 启动服务器
|
||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||
return r.Run(fmt.Sprintf(":%d", port))
|
||
}
|
||
|
||
// indexHandler 处理主页请求
|
||
func indexHandler(c *gin.Context) {
|
||
data := types.PageData{
|
||
Title: "英卓气象站",
|
||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||
TiandituKey: "0c260b8a094a4e0bc507808812cefdac",
|
||
}
|
||
c.HTML(http.StatusOK, "index.html", data)
|
||
}
|
||
|
||
// 备注:雷达站采用前端 Tab(hash)切换,无需单独路由
|
||
|
||
// systemStatusHandler 处理系统状态API请求
|
||
func systemStatusHandler(c *gin.Context) {
|
||
status := types.SystemStatus{
|
||
OnlineDevices: database.GetOnlineDevicesCount(database.GetDB()),
|
||
ServerTime: time.Now().Format("2006-01-02 15:04:05"),
|
||
}
|
||
c.JSON(http.StatusOK, status)
|
||
}
|
||
|
||
// getStationsHandler 处理获取站点列表API请求
|
||
func getStationsHandler(c *gin.Context) {
|
||
stations, err := database.GetStations(database.GetDB())
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询站点失败"})
|
||
return
|
||
}
|
||
|
||
// 为每个站点计算十进制ID
|
||
for i := range stations {
|
||
if len(stations[i].StationID) > 6 {
|
||
hexID := stations[i].StationID[len(stations[i].StationID)-6:]
|
||
if decimalID, err := strconv.ParseInt(hexID, 16, 64); err == nil {
|
||
stations[i].DecimalID = strconv.FormatInt(decimalID, 10)
|
||
}
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, stations)
|
||
}
|
||
|
||
// getDataHandler 处理获取历史数据API请求
|
||
func getDataHandler(c *gin.Context) {
|
||
// 获取查询参数
|
||
decimalID := c.Query("decimal_id")
|
||
startTime := c.Query("start_time")
|
||
endTime := c.Query("end_time")
|
||
interval := c.Query("interval")
|
||
|
||
// 将十进制ID转换为十六进制(补足6位)
|
||
decimalNum, err := strconv.ParseInt(decimalID, 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的站点编号"})
|
||
return
|
||
}
|
||
hexID := fmt.Sprintf("%06X", decimalNum)
|
||
stationID := fmt.Sprintf("RS485-%s", hexID)
|
||
|
||
// 解析时间(按本地CST解析,避免被当作UTC)
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
if loc == nil {
|
||
loc = time.FixedZone("CST", 8*3600)
|
||
}
|
||
start, err := time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间"})
|
||
return
|
||
}
|
||
|
||
end, err := time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间"})
|
||
return
|
||
}
|
||
|
||
// 获取数据(改为基于10分钟聚合表的再聚合)
|
||
var points []types.WeatherPoint
|
||
if interval == "raw" {
|
||
points, err = database.GetSeriesRaw(database.GetDB(), stationID, start, end)
|
||
} else {
|
||
points, err = database.GetSeriesFrom10Min(database.GetDB(), stationID, start, end, interval)
|
||
}
|
||
if err != nil {
|
||
log.Printf("查询数据失败: %v", err) // 记录具体错误到服务端日志
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"error": fmt.Sprintf("查询数据失败: %v", err),
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, points)
|
||
}
|
||
|
||
// getForecastHandler 处理获取预报数据API请求
|
||
func getForecastHandler(c *gin.Context) {
|
||
// 获取查询参数
|
||
stationID := c.Query("station_id")
|
||
startTime := c.Query("from")
|
||
endTime := c.Query("to")
|
||
provider := c.Query("provider")
|
||
versionsStr := c.DefaultQuery("versions", "1")
|
||
versions, _ := strconv.Atoi(versionsStr)
|
||
if versions <= 0 {
|
||
versions = 1
|
||
}
|
||
|
||
if stationID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少station_id参数"})
|
||
return
|
||
}
|
||
|
||
// 如果没有提供时间范围,则默认查询未来3小时
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
if loc == nil {
|
||
loc = time.FixedZone("CST", 8*3600)
|
||
}
|
||
|
||
var start, end time.Time
|
||
var err error
|
||
|
||
if startTime == "" || endTime == "" {
|
||
// 默认查询未来3小时
|
||
now := time.Now().In(loc)
|
||
start = now.Truncate(time.Hour).Add(1 * time.Hour) // 下一个整点开始
|
||
end = start.Add(3 * time.Hour) // 未来3小时
|
||
} else {
|
||
// 解析用户提供的时间
|
||
start, err = time.ParseInLocation("2006-01-02 15:04:05", startTime, loc)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的开始时间格式"})
|
||
return
|
||
}
|
||
|
||
end, err = time.ParseInLocation("2006-01-02 15:04:05", endTime, loc)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的结束时间格式"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 获取预报数据
|
||
log.Printf("查询预报数据: stationID=%s, provider=%s, versions=%d, start=%s, end=%s", stationID, provider, versions, 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, versions)
|
||
if err != nil {
|
||
log.Printf("查询预报数据失败: %v", err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{
|
||
"error": fmt.Sprintf("查询预报数据失败: %v", err),
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("查询到预报数据: %d 条", len(points))
|
||
c.JSON(http.StatusOK, points)
|
||
}
|
||
|
||
// radarLatestHandler 返回最新一次雷达抓取的元数据与图片URL
|
||
func radarLatestHandler(c *gin.Context) {
|
||
// 读取 latest/metadata.json
|
||
latestRoot := "./radar_data/latest"
|
||
metaPath := latestRoot + "/metadata.json"
|
||
b, err := os.ReadFile(metaPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||
return
|
||
}
|
||
var meta map[string]any
|
||
if err := json.Unmarshal(b, &meta); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||
return
|
||
}
|
||
// 构造图片URL(通过 /radar/latest/* 静态路径访问)
|
||
images := map[string]string{
|
||
"china": "/radar/latest/nmc_chinaall.png",
|
||
"huanan": "/radar/latest/nmc_huanan.png",
|
||
"nanning": "/radar/latest/nmc_nanning.png",
|
||
}
|
||
if files, ok := meta["files"].(map[string]any); ok {
|
||
if v, ok2 := files["cma_png"].(string); ok2 && v != "" {
|
||
_, name := path.Split(v)
|
||
images["cma"] = "/radar/latest/" + name
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"meta": meta,
|
||
"images": images,
|
||
})
|
||
}
|
||
|
||
// radarLatestGridHandler 读取 latest 下的 z-y-x.bin 并返回 256x256 的 dBZ 二维数组(无效为 null)
|
||
func radarLatestGridHandler(c *gin.Context) {
|
||
latestRoot := "./radar_data/latest"
|
||
metaPath := latestRoot + "/metadata.json"
|
||
b, err := os.ReadFile(metaPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||
return
|
||
}
|
||
var meta map[string]any
|
||
if err := json.Unmarshal(b, &meta); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||
return
|
||
}
|
||
z := intFromMeta(meta, "z")
|
||
y := intFromMeta(meta, "y")
|
||
x := intFromMeta(meta, "x")
|
||
binName := fmt.Sprintf("%d-%d-%d.bin", z, y, x)
|
||
binPath := path.Join(latestRoot, binName)
|
||
buf, err := os.ReadFile(binPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新BIN文件"})
|
||
return
|
||
}
|
||
const w, h = 256, 256
|
||
if len(buf) != w*h*2 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "BIN尺寸异常"})
|
||
return
|
||
}
|
||
grid := make([][]*float64, h)
|
||
for r := 0; r < h; r++ {
|
||
row := make([]*float64, w)
|
||
for c2 := 0; c2 < w; c2++ {
|
||
off := (r*w + c2) * 2
|
||
u := uint16(buf[off])<<8 | uint16(buf[off+1])
|
||
v := int16(u)
|
||
if v == 32767 || v < 0 {
|
||
row[c2] = nil
|
||
continue
|
||
}
|
||
dbz := float64(v) / 10.0
|
||
row[c2] = &dbz
|
||
}
|
||
grid[r] = row
|
||
}
|
||
bounds := map[string]float64{"west": 0, "south": 0, "east": 0, "north": 0}
|
||
if v, ok := meta["bounds"].(map[string]any); ok {
|
||
if f, ok2 := v["west"].(float64); ok2 {
|
||
bounds["west"] = f
|
||
}
|
||
if f, ok2 := v["south"].(float64); ok2 {
|
||
bounds["south"] = f
|
||
}
|
||
if f, ok2 := v["east"].(float64); ok2 {
|
||
bounds["east"] = f
|
||
}
|
||
if f, ok2 := v["north"].(float64); ok2 {
|
||
bounds["north"] = f
|
||
}
|
||
}
|
||
resDeg := 0.0
|
||
if f, ok := meta["res_deg"].(float64); ok {
|
||
resDeg = f
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"z": z, "y": y, "x": x,
|
||
"bounds": bounds,
|
||
"res_deg": resDeg,
|
||
"grid": grid,
|
||
})
|
||
}
|
||
|
||
func intFromMeta(m map[string]any, key string) int {
|
||
if v, ok := m[key]; ok {
|
||
switch t := v.(type) {
|
||
case float64:
|
||
return int(t)
|
||
case int:
|
||
return t
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// radarLatestWindHandler queries Caiyun realtime wind for the latest query candidates
|
||
// and provides per-cluster aggregated wind and basic coming/ETA analysis toward station.
|
||
func radarLatestWindHandler(c *gin.Context) {
|
||
// Constants per user request
|
||
const (
|
||
stationLat = 23.097234
|
||
stationLon = 108.715433
|
||
)
|
||
// Read latest metadata into struct
|
||
latestRoot := "./radar_data/latest"
|
||
metaPath := latestRoot + "/metadata.json"
|
||
b, err := os.ReadFile(metaPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到最新雷达元数据"})
|
||
return
|
||
}
|
||
var meta rf.Metadata
|
||
if err := json.Unmarshal(b, &meta); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析元数据失败"})
|
||
return
|
||
}
|
||
// For each query candidate, call Caiyun
|
||
type Wind struct {
|
||
Speed float64 `json:"speed_ms"`
|
||
DirFrom float64 `json:"dir_from_deg"`
|
||
DirTo float64 `json:"dir_to_deg"`
|
||
U float64 `json:"u_east_ms"`
|
||
V float64 `json:"v_north_ms"`
|
||
TempC float64 `json:"temp_c"`
|
||
RH float64 `json:"rh"` // 0-1
|
||
PressureHpa float64 `json:"pressure_hpa"`
|
||
}
|
||
type CandOut struct {
|
||
rf.QueryCandidate
|
||
Wind *Wind `json:"wind,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
outs := make([]CandOut, 0, len(meta.QueryCandidates))
|
||
for _, q := range meta.QueryCandidates {
|
||
speed, dirFrom, tempC, rh, pPa, err := rf.FetchCaiyunRealtime(q.Lon, q.Lat)
|
||
co := CandOut{QueryCandidate: q}
|
||
if err != nil {
|
||
co.Error = err.Error()
|
||
} else {
|
||
dirTo := mathMod(dirFrom+180.0, 360.0)
|
||
u, v := windVectorUV(speed, dirTo)
|
||
// pressure in hPa for display
|
||
pHpa := pPa / 100.0
|
||
co.Wind = &Wind{Speed: speed, DirFrom: dirFrom, DirTo: dirTo, U: u, V: v, TempC: tempC, RH: rh, PressureHpa: pHpa}
|
||
}
|
||
outs = append(outs, co)
|
||
}
|
||
// Aggregate by cluster id
|
||
agg := map[int][]Wind{}
|
||
for _, co := range outs {
|
||
if co.Wind == nil {
|
||
continue
|
||
}
|
||
agg[co.ClusterID] = append(agg[co.ClusterID], *co.Wind)
|
||
}
|
||
type ClusterAnal struct {
|
||
ClusterID int `json:"cluster_id"`
|
||
Lon float64 `json:"lon"`
|
||
Lat float64 `json:"lat"`
|
||
AreaPx int `json:"area_px"`
|
||
MaxDBZ float64 `json:"max_dbz"`
|
||
SpeedMS float64 `json:"speed_ms"`
|
||
DirToDeg float64 `json:"dir_to_deg"`
|
||
U float64 `json:"u_east_ms"`
|
||
V float64 `json:"v_north_ms"`
|
||
Coming bool `json:"coming"`
|
||
ETAMin float64 `json:"eta_min,omitempty"`
|
||
DistanceKm float64 `json:"distance_km"`
|
||
LateralKm float64 `json:"lateral_km"`
|
||
RCloudKm float64 `json:"r_cloud_km"`
|
||
}
|
||
analyses := []ClusterAnal{}
|
||
// helpers
|
||
mPerDegLat := 111320.0
|
||
mPerDegLon := func(lat float64) float64 { return 111320.0 * math.Cos(lat*math.Pi/180.0) }
|
||
cellDims := func(lat float64) (float64, float64) { // width (lon), height (lat) in meters per pixel
|
||
return meta.ResDeg * mPerDegLon(lat), meta.ResDeg * mPerDegLat
|
||
}
|
||
const hitRadiusM = 5000.0
|
||
for _, cl := range meta.Clusters {
|
||
winds := agg[cl.ID]
|
||
if len(winds) == 0 {
|
||
continue
|
||
}
|
||
// vector average in u,v (to-direction)
|
||
sumU, sumV := 0.0, 0.0
|
||
for _, wv := range winds {
|
||
sumU += wv.U
|
||
sumV += wv.V
|
||
}
|
||
u := sumU / float64(len(winds))
|
||
v := sumV / float64(len(winds))
|
||
speed := math.Hypot(u, v)
|
||
dirTo := uvToDirTo(u, v)
|
||
// project geometry
|
||
wx, wy := mPerDegLon(cl.Lat), mPerDegLat
|
||
// position of cluster and station in meters (local tangent plane)
|
||
px := (cl.Lon - stationLon) * wx
|
||
py := (cl.Lat - stationLat) * wy
|
||
// vector from cluster to station
|
||
dx := -px
|
||
dy := -py
|
||
d := math.Hypot(dx, dy)
|
||
// radial component of velocity towards station
|
||
if d == 0 {
|
||
d = 1e-6
|
||
}
|
||
vr := (dx*u + dy*v) / d
|
||
// cluster equivalent radius
|
||
cw, ch := cellDims(cl.Lat)
|
||
areaM2 := float64(cl.AreaPx) * cw * ch
|
||
rCloud := math.Sqrt(areaM2 / math.Pi)
|
||
// lateral offset (perpendicular distance from station line)
|
||
vnorm := math.Hypot(u, v)
|
||
lateral := 0.0
|
||
if vnorm > 0 {
|
||
// |d x vhat|
|
||
vx, vy := u/vnorm, v/vnorm
|
||
lateral = math.Abs(dx*vy - dy*vx)
|
||
}
|
||
coming := vr > 0 && lateral <= (rCloud+hitRadiusM)
|
||
etaMin := 0.0
|
||
if coming && vr > 0 {
|
||
distToEdge := d - (rCloud + hitRadiusM)
|
||
if distToEdge < 0 {
|
||
distToEdge = 0
|
||
}
|
||
etaMin = distToEdge / vr / 60.0
|
||
}
|
||
analyses = append(analyses, ClusterAnal{
|
||
ClusterID: cl.ID,
|
||
Lon: cl.Lon, Lat: cl.Lat,
|
||
AreaPx: cl.AreaPx, MaxDBZ: cl.MaxDBZ,
|
||
SpeedMS: speed, DirToDeg: dirTo, U: u, V: v,
|
||
Coming: coming, ETAMin: round2(etaMin),
|
||
DistanceKm: round2(d / 1000.0), LateralKm: round2(lateral / 1000.0), RCloudKm: round2(rCloud / 1000.0),
|
||
})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"station": gin.H{"lon": stationLon, "lat": stationLat},
|
||
"params": meta.QueryParams,
|
||
"candidates": outs,
|
||
"clusters": analyses,
|
||
})
|
||
}
|
||
|
||
func windVectorUV(speed, dirTo float64) (u, v float64) {
|
||
// dirTo: 0=north, 90=east
|
||
rad := dirTo * math.Pi / 180.0
|
||
u = speed * math.Sin(rad)
|
||
v = speed * math.Cos(rad)
|
||
return
|
||
}
|
||
|
||
func uvToDirTo(u, v float64) float64 {
|
||
// inverse of above
|
||
rad := math.Atan2(u, v) // atan2(y,x) but here y=u (east), x=v (north)
|
||
deg := rad * 180.0 / math.Pi
|
||
if deg < 0 {
|
||
deg += 360.0
|
||
}
|
||
return deg
|
||
}
|
||
|
||
func mathMod(a, m float64) float64 { // positive modulo
|
||
r := math.Mod(a, m)
|
||
if r < 0 {
|
||
r += m
|
||
}
|
||
return r
|
||
}
|
||
|
||
func round2(x float64) float64 { return math.Round(x*100.0) / 100.0 }
|