// server 是可转债轮动分析系统的 B/S 服务入口。 package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "stock-analysis/internal/cache" "stock-analysis/internal/controller" "stock-analysis/internal/datasource/eastmoney" "stock-analysis/internal/datasource/fallback" "stock-analysis/internal/datasource/tencent" "stock-analysis/internal/job" "stock-analysis/internal/router" "stock-analysis/internal/service" "stock-analysis/internal/storage/sqlite" ) func main() { addr := ":8080" if p := os.Getenv("PORT"); p != "" { addr = ":" + p } webDir := "web" dbPath := defaultDBPath() // 1. 共享 SQLite 存储(MarketController / AuthController / job.Manager 共用)。 store, err := sqlite.Open(dbPath) if err != nil { log.Fatalf("open sqlite: %v", err) } defer store.Close() // 2. 行情数据源:东财为主,腾讯为备用(实时行情 + 历史K线,默认 2s 节流)。 marketEM := eastmoney.NewClient() marketDS := fallback.New(marketEM, tencent.NewClient()) svc := service.NewMarketService(marketDS) mc := controller.NewMarketController(svc, store) // 3. 认证服务。 authSvc := service.NewAuthService(store) if err := authSvc.EnsureAdmin(context.Background()); err != nil { log.Printf("ensure admin: %v", err) } ac := controller.NewAuthController(authSvc) // 4. Redis 缓存:任务实时状态存储。Redis 不可用时降级到 NoopCache,任务仍可运行(回退 SQLite)。 taskCache := initCache() // 5. 任务数据源:批量入库场景禁用 per-client 节流,由 job.Manager 全局限速器统一控制速率。 jobEM := eastmoney.NewClientWithThrottle(0) jobDS := fallback.New(jobEM, tencent.NewClient()) mgr := job.NewManager(store, taskCache, jobDS, jobEM, job.Config{ Workers: envInt("JOB_WORKERS", 8), BatchSize: envInt("JOB_BATCH_SIZE", 20), RateInterval: 400 * time.Millisecond, RateBurst: 4, }) defer mgr.StopAll() tc := controller.NewTaskController(mgr) // 6. 路由装配。 handler := router.New(mc, ac, tc, webDir) server := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, } // 优雅退出:收到信号后关闭服务。 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() go func() { log.Printf("stock-analysis server listening on http://localhost%s", addr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server: %v", err) } }() <-ctx.Done() log.Println("shutting down server...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("shutdown: %v", err) } } // initCache 初始化 Redis 缓存,连接失败时降级到 NoopCache。 // Redis 地址通过 REDIS_ADDR 环境变量配置(默认 localhost:6379),密码通过 REDIS_PASSWORD 配置。 func initCache() cache.Cache { addr := os.Getenv("REDIS_ADDR") if addr == "" { addr = "localhost:6379" } password := os.Getenv("REDIS_PASSWORD") rc, err := cache.NewRedisCache(addr, password, 0) if err != nil { log.Printf("[cache] redis create failed (%s), using noop cache: %v", addr, err) return cache.NoopCache{} } pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := rc.Ping(pingCtx); err != nil { log.Printf("[cache] redis ping failed (%s), using noop cache: %v", addr, err) return cache.NoopCache{} } log.Printf("[cache] redis connected: %s", addr) return rc } // envInt 读取环境变量整数值,缺失或无效时返回默认值。 func envInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := atoi(v); err == nil { return n } } return def } // atoi 简易字符串转整数(避免引入 strconv 仅为此一处)。 func atoi(s string) (int, error) { n := 0 for _, c := range s { if c < '0' || c > '9' { return 0, errors.New("invalid int") } n = n*10 + int(c-'0') } return n, nil } // defaultDBPath 返回默认数据库路径。 // 默认使用项目目录下的 stock.db,避免 OneDrive/AppData 的 I/O 限制。 func defaultDBPath() string { if env := os.Getenv("STOCK_DB_PATH"); env != "" { return env } exe, err := os.Executable() if err != nil { return "stock.db" } return filepath.Join(filepath.Dir(exe), "stock.db") }