507 lines
15 KiB
Go
507 lines
15 KiB
Go
package server
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"time"
|
||
"weatherstation/internal/config"
|
||
"weatherstation/internal/database"
|
||
"weatherstation/pkg/types"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// newGinEngine 统一创建和配置 Gin 引擎(模板/静态/路由)
|
||
func newGinEngine() *gin.Engine {
|
||
// 设置Gin模式
|
||
gin.SetMode(gin.ReleaseMode)
|
||
|
||
// 创建Gin引擎
|
||
r := gin.Default()
|
||
|
||
// 以可执行文件所在目录为基准查找资源(优先),其次回退到相对路径
|
||
exe, _ := os.Executable()
|
||
exeDir := filepath.Dir(exe)
|
||
|
||
// 模板目录
|
||
candidatesTpl := []string{
|
||
filepath.Join(exeDir, "templates", "*"),
|
||
filepath.Join(exeDir, "..", "templates", "*"),
|
||
filepath.Join("templates", "*"),
|
||
filepath.Join("..", "templates", "*"),
|
||
}
|
||
var tplGlob string
|
||
for _, c := range candidatesTpl {
|
||
base := filepath.Dir(c)
|
||
if st, err := os.Stat(base); err == nil && st.IsDir() {
|
||
tplGlob = c
|
||
break
|
||
}
|
||
}
|
||
if tplGlob == "" {
|
||
tplGlob = filepath.Join("templates", "*")
|
||
}
|
||
r.LoadHTMLGlob(tplGlob)
|
||
|
||
// 静态目录
|
||
candidatesStatic := []string{
|
||
filepath.Join(exeDir, "static"),
|
||
filepath.Join(exeDir, "..", "static"),
|
||
"./static",
|
||
"../static",
|
||
}
|
||
staticDir := candidatesStatic[0]
|
||
for _, c := range candidatesStatic {
|
||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||
staticDir = c
|
||
break
|
||
}
|
||
}
|
||
r.Static("/static", staticDir)
|
||
|
||
// 前端SPA(Angular)静态资源与路由回退
|
||
// 构建产物目录(可执行目录优先)
|
||
r.GET("/ui/*filepath", func(c *gin.Context) {
|
||
// 物理文件优先,否则回退到 index.html(支持前端路由)
|
||
requested := c.Param("filepath")
|
||
if requested == "" || requested == "/" {
|
||
// index.html
|
||
candidates := []string{
|
||
filepath.Join(exeDir, "core/frontend/dist/ui/index.html"),
|
||
filepath.Join(exeDir, "..", "core/frontend/dist/ui/index.html"),
|
||
"./core/frontend/dist/ui/index.html",
|
||
"../core/frontend/dist/ui/index.html",
|
||
}
|
||
for _, p := range candidates {
|
||
if _, err := os.Stat(p); err == nil {
|
||
c.File(p)
|
||
return
|
||
}
|
||
}
|
||
c.String(http.StatusNotFound, "ui not found")
|
||
return
|
||
}
|
||
// 选择 baseDir
|
||
baseDirCandidates := []string{
|
||
filepath.Join(exeDir, "core/frontend/dist/ui"),
|
||
filepath.Join(exeDir, "..", "core/frontend/dist/ui"),
|
||
"./core/frontend/dist/ui",
|
||
"../core/frontend/dist/ui",
|
||
}
|
||
baseDir := baseDirCandidates[0]
|
||
for _, d := range baseDirCandidates {
|
||
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
||
baseDir = d
|
||
break
|
||
}
|
||
}
|
||
full := baseDir + requested
|
||
if _, err := os.Stat(full); err == nil {
|
||
c.File(full)
|
||
return
|
||
}
|
||
c.File(filepath.Join(baseDir, "index.html"))
|
||
})
|
||
|
||
// 路由设置
|
||
r.GET("/", indexHandler)
|
||
r.GET("/bigscreen", bigscreenHandler)
|
||
r.GET("/radar/nanning", radarNanningHandler)
|
||
r.GET("/radar/guangzhou", radarGuangzhouHandler)
|
||
r.GET("/radar/panyu", radarPanyuHandler)
|
||
r.GET("/radar/haizhu", radarHaizhuHandler)
|
||
r.GET("/radar/imdroid", imdroidRadarHandler)
|
||
|
||
// 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", latestRadarTileHandler)
|
||
api.GET("/radar/at", radarTileAtHandler)
|
||
api.GET("/radar/nearest", nearestRadarTileHandler)
|
||
api.GET("/radar/times", radarTileTimesHandler)
|
||
// multi-tiles at same dt
|
||
api.GET("/radar/tiles_at", radarTilesAtHandler)
|
||
api.GET("/radar/weather_latest", latestRadarWeatherHandler)
|
||
api.GET("/radar/weather_at", radarWeatherAtHandler)
|
||
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)
|
||
api.GET("/radar/aliases", radarConfigAliasesHandler)
|
||
api.GET("/radar/weather_nearest", radarWeatherNearestHandler)
|
||
// Rain CMPA hourly tiles
|
||
api.GET("/rain/latest", latestRainTileHandler)
|
||
api.GET("/rain/at", rainTileAtHandler)
|
||
api.GET("/rain/nearest", nearestRainTileHandler)
|
||
api.GET("/rain/times", rainTileTimesHandler)
|
||
api.GET("/rain/tiles_at", rainTilesAtHandler)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// StartGinServer 启动Gin Web服务器(配置端口)
|
||
func StartGinServer() error {
|
||
r := newGinEngine()
|
||
// 获取配置的Web端口
|
||
port := config.GetConfig().Server.WebPort
|
||
if port == 0 {
|
||
port = 10003 // 默认端口
|
||
}
|
||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||
return r.Run(fmt.Sprintf(":%d", port))
|
||
}
|
||
|
||
// StartGinServerOn 在指定端口启动一个独立的 Gin 实例
|
||
func StartGinServerOn(port int) error {
|
||
r := newGinEngine()
|
||
if port <= 0 {
|
||
port = 10008
|
||
}
|
||
fmt.Printf("Gin Web服务器启动,监听端口 %d...\n", port)
|
||
return r.Run(fmt.Sprintf(":%d", port))
|
||
}
|
||
|
||
// StartBigscreenServerOn 在指定端口启动以大屏为根路径的 Gin 实例(/ -> bigscreen)
|
||
func StartBigscreenServerOn(port int) error {
|
||
// 独立构建一个以大屏为根路径的引擎,避免与普通引擎重复注册根路由
|
||
gin.SetMode(gin.ReleaseMode)
|
||
r := gin.Default()
|
||
|
||
exe, _ := os.Executable()
|
||
exeDir := filepath.Dir(exe)
|
||
|
||
// 模板
|
||
candidatesTpl := []string{
|
||
filepath.Join(exeDir, "templates", "*"),
|
||
filepath.Join(exeDir, "..", "templates", "*"),
|
||
filepath.Join("templates", "*"),
|
||
filepath.Join("..", "templates", "*"),
|
||
}
|
||
var tplGlob string
|
||
for _, c := range candidatesTpl {
|
||
base := filepath.Dir(c)
|
||
if st, err := os.Stat(base); err == nil && st.IsDir() {
|
||
tplGlob = c
|
||
break
|
||
}
|
||
}
|
||
if tplGlob == "" {
|
||
tplGlob = filepath.Join("templates", "*")
|
||
}
|
||
r.LoadHTMLGlob(tplGlob)
|
||
|
||
// 静态资源
|
||
candidatesStatic := []string{
|
||
filepath.Join(exeDir, "static"),
|
||
filepath.Join(exeDir, "..", "static"),
|
||
"./static",
|
||
"../static",
|
||
}
|
||
staticDir := candidatesStatic[0]
|
||
for _, c := range candidatesStatic {
|
||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||
staticDir = c
|
||
break
|
||
}
|
||
}
|
||
r.Static("/static", staticDir)
|
||
|
||
// SPA 资源
|
||
r.GET("/ui/*filepath", func(c *gin.Context) {
|
||
requested := c.Param("filepath")
|
||
if requested == "" || requested == "/" {
|
||
candidates := []string{
|
||
filepath.Join(exeDir, "core/frontend/dist/ui/index.html"),
|
||
filepath.Join(exeDir, "..", "core/frontend/dist/ui/index.html"),
|
||
"./core/frontend/dist/ui/index.html",
|
||
"../core/frontend/dist/ui/index.html",
|
||
}
|
||
for _, p := range candidates {
|
||
if _, err := os.Stat(p); err == nil {
|
||
c.File(p)
|
||
return
|
||
}
|
||
}
|
||
c.String(http.StatusNotFound, "ui not found")
|
||
return
|
||
}
|
||
baseDirCandidates := []string{
|
||
filepath.Join(exeDir, "core/frontend/dist/ui"),
|
||
filepath.Join(exeDir, "..", "core/frontend/dist/ui"),
|
||
"./core/frontend/dist/ui",
|
||
"../core/frontend/dist/ui",
|
||
}
|
||
baseDir := baseDirCandidates[0]
|
||
for _, d := range baseDirCandidates {
|
||
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
||
baseDir = d
|
||
break
|
||
}
|
||
}
|
||
full := baseDir + requested
|
||
if _, err := os.Stat(full); err == nil {
|
||
c.File(full)
|
||
return
|
||
}
|
||
c.File(filepath.Join(baseDir, "index.html"))
|
||
})
|
||
|
||
// 根路径 -> 大屏
|
||
r.GET("/", bigscreenHandler)
|
||
|
||
// 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", latestRadarTileHandler)
|
||
api.GET("/radar/at", radarTileAtHandler)
|
||
api.GET("/radar/nearest", nearestRadarTileHandler)
|
||
api.GET("/radar/times", radarTileTimesHandler)
|
||
api.GET("/radar/tiles_at", radarTilesAtHandler)
|
||
api.GET("/radar/weather_latest", latestRadarWeatherHandler)
|
||
api.GET("/radar/weather_at", radarWeatherAtHandler)
|
||
api.GET("/radar/weather_aliases", radarWeatherAliasesHandler)
|
||
api.GET("/radar/aliases", radarConfigAliasesHandler)
|
||
api.GET("/radar/weather_nearest", radarWeatherNearestHandler)
|
||
api.GET("/rain/latest", latestRainTileHandler)
|
||
api.GET("/rain/at", rainTileAtHandler)
|
||
api.GET("/rain/nearest", nearestRainTileHandler)
|
||
api.GET("/rain/times", rainTileTimesHandler)
|
||
api.GET("/rain/tiles_at", rainTilesAtHandler)
|
||
}
|
||
|
||
if port <= 0 {
|
||
port = 10008
|
||
}
|
||
fmt.Printf("Gin Bigscreen 服务器启动,监听端口 %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)
|
||
}
|
||
|
||
// bigscreenHandler 大屏演示页
|
||
func bigscreenHandler(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, "bigscreen.html", data)
|
||
}
|
||
|
||
// radarNanningHandler 南宁雷达站占位页
|
||
func radarNanningHandler(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, "imdroid_radar.html", data)
|
||
}
|
||
|
||
// radarGuangzhouHandler 广州雷达站占位页
|
||
func radarGuangzhouHandler(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, "imdroid_radar.html", data)
|
||
}
|
||
|
||
// radarHaizhuHandler 海珠雷达站占位页
|
||
func radarHaizhuHandler(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, "imdroid_radar.html", data)
|
||
}
|
||
|
||
// radarPanyuHandler 番禺雷达站占位页
|
||
func radarPanyuHandler(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, "imdroid_radar.html", data)
|
||
}
|
||
|
||
func imdroidRadarHandler(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, "imdroid_radar.html", data)
|
||
}
|
||
|
||
// 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)
|
||
}
|