新增完整的异步任务管理体系: 1. 新增Redis缓存层实现任务状态实时存储与降级机制 2. 实现并发拉取+批量写入的高性能入库流程 3. 添加任务管理HTTP接口与路由 4. 重构行情控制器使用共享SQLite存储 5. 配套添加Docker Compose Redis启动配置 6. 前端新增任务管理面板支持操作与进度查看
156 lines
4.4 KiB
Go
156 lines
4.4 KiB
Go
// 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")
|
||
}
|