258 lines
6.1 KiB
Go
258 lines
6.1 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"api-server/docs"
|
||
"api-server/internal/analytics"
|
||
"api-server/internal/config"
|
||
"api-server/internal/handler"
|
||
"api-server/internal/middleware"
|
||
"api-server/web"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
swaggerFiles "github.com/swaggo/files"
|
||
ginSwagger "github.com/swaggo/gin-swagger"
|
||
)
|
||
|
||
// Server HTTP 服务
|
||
type Server struct {
|
||
cfg *config.Config
|
||
engine *gin.Engine
|
||
stats *analytics.Engine
|
||
geoip *analytics.GeoIP
|
||
srv *http.Server
|
||
}
|
||
|
||
// New 创建服务实例
|
||
func New(cfg *config.Config) (*Server, error) {
|
||
// 设置 gin 模式
|
||
gin.SetMode(cfg.Server.Mode)
|
||
|
||
// 初始化 IP 地理位置
|
||
geoip, err := analytics.NewGeoIP(cfg.GeoIP.DBPath, true)
|
||
if err != nil {
|
||
log.Printf("[server] 初始化 GeoIP 失败: %v", err)
|
||
geoip = nil
|
||
}
|
||
|
||
// 初始化统计引擎
|
||
statsEngine := analytics.NewEngine(geoip)
|
||
|
||
// 初始化 gin
|
||
router := gin.New()
|
||
|
||
// 全局中间件
|
||
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||
return fmt.Sprintf("[%s] %s %s %d %s %s\n",
|
||
param.TimeStamp.Format("15:04:05"),
|
||
param.ClientIP,
|
||
param.Method,
|
||
param.StatusCode,
|
||
param.Latency,
|
||
param.Path,
|
||
)
|
||
}))
|
||
router.Use(gin.Recovery())
|
||
router.Use(middleware.Analytics(statsEngine))
|
||
|
||
// ── 注册核心业务 API ──────────────────────────────────
|
||
base := handler.New(cfg)
|
||
api := router.Group("/api")
|
||
{
|
||
api.GET("/health", base.Health)
|
||
api.GET("/ping", base.Ping)
|
||
}
|
||
|
||
// sol148-extractor 业务 API
|
||
extractHandler := handler.NewExtractHandler()
|
||
extractHandler.RegisterRoutes(api)
|
||
|
||
contentHandler := handler.NewContentHandler()
|
||
contentHandler.RegisterRoutes(api)
|
||
|
||
// 统计分析 API(监控用)
|
||
statsHandler := handler.NewStats(statsEngine)
|
||
statsHandler.RegisterRoutes(api)
|
||
|
||
// 视频代理(绕过 CDN Referer 限制)
|
||
proxyHandler := handler.NewProxyHandler()
|
||
proxyHandler.RegisterRoutes(api)
|
||
|
||
// Swagger 文档
|
||
docs.SwaggerInfo.Host = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||
|
||
// ── 嵌入式前端(禁用缓存,防止浏览器缓存旧重定向) ──
|
||
|
||
noCache := func(c *gin.Context) {
|
||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||
c.Header("Pragma", "no-cache")
|
||
c.Header("Expires", "0")
|
||
}
|
||
|
||
// Dashboard 首页
|
||
router.GET("/", func(c *gin.Context) {
|
||
noCache(c)
|
||
serveStaticFile(c, "index.html")
|
||
})
|
||
|
||
// 视频网站入口(显式注册两版本避免 301)
|
||
router.GET("/video", func(c *gin.Context) {
|
||
noCache(c)
|
||
serveStaticFile(c, "videoapp/index.html")
|
||
})
|
||
router.GET("/video/", func(c *gin.Context) {
|
||
noCache(c)
|
||
serveStaticFile(c, "videoapp/index.html")
|
||
})
|
||
|
||
// NoRoute: SPA fallback + 静态文件
|
||
router.NoRoute(func(c *gin.Context) {
|
||
path := c.Request.URL.Path
|
||
|
||
// 跳过 API 路径
|
||
if strings.HasPrefix(path, "/api") {
|
||
c.String(http.StatusNotFound, "Not Found")
|
||
return
|
||
}
|
||
|
||
// 视频网站子路由 → videoapp SPA
|
||
if path == "/video" || strings.HasPrefix(path, "/video/") {
|
||
noCache(c)
|
||
serveStaticFile(c, "videoapp/index.html")
|
||
return
|
||
}
|
||
|
||
// 尝试作为静态文件
|
||
trimmed := strings.TrimPrefix(path, "/")
|
||
data, err := readStaticFile(trimmed)
|
||
if err == nil {
|
||
noCache(c)
|
||
c.Data(http.StatusOK, mimeByExt(trimmed), data)
|
||
return
|
||
}
|
||
|
||
// 兜底:Dashboard SPA
|
||
noCache(c)
|
||
serveStaticFile(c, "index.html")
|
||
})
|
||
|
||
srv := &http.Server{
|
||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||
Handler: router,
|
||
ReadTimeout: cfg.Server.ReadTimeout,
|
||
WriteTimeout: cfg.Server.WriteTimeout,
|
||
}
|
||
|
||
return &Server{
|
||
cfg: cfg,
|
||
engine: router,
|
||
stats: statsEngine,
|
||
geoip: geoip,
|
||
srv: srv,
|
||
}, nil
|
||
}
|
||
|
||
// Run 启动服务(阻塞直到收到信号)
|
||
func (s *Server) Run() error {
|
||
// 定期输出统计
|
||
go func() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
s.stats.LogStats()
|
||
}
|
||
}
|
||
}()
|
||
|
||
// 启动 HTTP 服务
|
||
go func() {
|
||
log.Printf("🚀 服务启动: http://%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||
log.Printf("📊 统计面板: http://%s:%d/", s.cfg.Server.Host, s.cfg.Server.Port)
|
||
log.Printf("🎬 视频网站: http://%s:%d/video", s.cfg.Server.Host, s.cfg.Server.Port)
|
||
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
log.Fatalf("服务启动失败: %v", err)
|
||
}
|
||
}()
|
||
|
||
// 等待退出信号
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
<-quit
|
||
log.Println("正在关闭服务...")
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
if err := s.srv.Shutdown(ctx); err != nil {
|
||
return fmt.Errorf("服务关闭失败: %w", err)
|
||
}
|
||
|
||
s.stats.Stop()
|
||
if s.geoip != nil {
|
||
s.geoip.Close()
|
||
}
|
||
|
||
log.Println("服务已关闭")
|
||
return nil
|
||
}
|
||
|
||
// ── 静态文件服务(直接从 embed.FS 读取,避免 http.FileServer 重定向问题) ─
|
||
|
||
// readStaticFile 从嵌入式文件系统读取文件
|
||
func readStaticFile(path string) ([]byte, error) {
|
||
f, err := web.StaticFS.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
return io.ReadAll(f)
|
||
}
|
||
|
||
// serveStaticFile 直接提供静态文件(无重定向)
|
||
func serveStaticFile(c *gin.Context, path string) {
|
||
data, err := readStaticFile(path)
|
||
if err != nil {
|
||
c.String(http.StatusNotFound, "Not Found")
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, mimeByExt(path), data)
|
||
}
|
||
|
||
// mimeByExt 根据扩展名返回 Content-Type
|
||
func mimeByExt(path string) string {
|
||
ext := strings.ToLower(filepath.Ext(path))
|
||
switch ext {
|
||
case ".html", ".htm":
|
||
return "text/html; charset=utf-8"
|
||
case ".css":
|
||
return "text/css; charset=utf-8"
|
||
case ".js":
|
||
return "application/javascript; charset=utf-8"
|
||
case ".json":
|
||
return "application/json; charset=utf-8"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".svg":
|
||
return "image/svg+xml"
|
||
case ".ico":
|
||
return "image/x-icon"
|
||
default:
|
||
return "application/octet-stream"
|
||
}
|
||
} |