Files
91-api-server/internal/server/server.go
2026-06-10 05:12:15 +00:00

258 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}
}