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) { // 使用极坐标法:对每个云团仅在质心取一次风,直接判定靠近与ETA // 常量:目标点(站点/雷达点)坐标 const ( stationLat = 23.097234 stationLon = 108.715433 ) // 读取最新元数据 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 } // 输出结构保持兼容:仍提供 candidates,但每个cluster仅一个(质心) 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.Clusters)) // 工具函数 mPerDegLat := 111320.0 mPerDegLon := func(lat float64) float64 { return 111320.0 * math.Cos(lat*math.Pi/180.0) } // 计算极坐标ETA(到站点本身,不再使用侧向与半径作为命中条件) approachETA := func(lonC, latC, speedMS, dirToDeg, lonS, latS float64) (coming bool, etaMin float64, distanceKm float64, vrMS float64) { wx := mPerDegLon(latC) wy := mPerDegLat dx := (lonS - lonC) * wx // 东向米 dy := (latS - latC) * wy // 北向米 D := math.Hypot(dx, dy) if D == 0 { return true, 0, 0, speedMS } // 云→站方位角(北=0,顺时针) theta := math.Atan2(dx, dy) * 180 / math.Pi if theta < 0 { theta += 360 } beta := mathMod(dirToDeg, 360.0) delta := (beta - theta) * math.Pi / 180.0 vr := speedMS * math.Cos(delta) // 指向站点的径向速度 if vr <= 0 { return false, -1, D / 1000.0, vr } etaSec := D / vr return true, etaSec / 60.0, D / 1000.0, vr } // 为每个云团质心取一次风,构造 candidates 与 per-cluster 分析 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"` VrMS float64 `json:"vr_ms"` } analyses := []ClusterAnal{} // 等效云半径与侧向距离仅用于展示(不再作为判定条件) cellDims := func(lat float64) (float64, float64) { // 每像素米宽/米高 return meta.ResDeg * mPerDegLon(lat), meta.ResDeg * mPerDegLat } for _, cl := range meta.Clusters { // 取质心风 speed, dirFrom, tempC, rh, pPa, err := rf.FetchCaiyunRealtime(cl.Lon, cl.Lat) q := rf.QueryCandidate{ClusterID: cl.ID, Role: "center", Lon: cl.Lon, Lat: cl.Lat} co := CandOut{QueryCandidate: q} if err != nil { co.Error = err.Error() outs = append(outs, co) // 即便取风失败,也继续下一个云团 continue } dirTo := mathMod(dirFrom+180.0, 360.0) u, v := windVectorUV(speed, dirTo) 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) // 极坐标法靠近/ETA(到站点) coming, etaMin, distKm, vr := approachETA(cl.Lon, cl.Lat, speed, dirTo, stationLon, stationLat) // 展示参数:侧向距与等效半径 // 侧向距 = 距离向量对速度方向单位向量的叉积绝对值 wx, wy := mPerDegLon(cl.Lat), mPerDegLat px := (stationLon - cl.Lon) * wx py := (stationLat - cl.Lat) * wy vnorm := math.Hypot(u, v) lateral := 0.0 if vnorm > 0 { vx, vy := u/vnorm, v/vnorm lateral = math.Abs(px*vy - py*vx) } cw, ch := cellDims(cl.Lat) areaM2 := float64(cl.AreaPx) * cw * ch rCloud := math.Sqrt(areaM2 / math.Pi) 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(distKm), LateralKm: round2(lateral / 1000.0), RCloudKm: round2(rCloud / 1000.0), VrMS: round2(vr), }) } 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 }