Files
stock-analysis/cmd/server/main.go
WarrenZhou 273fc90a95 feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示
新增完整的异步任务管理体系:
1. 新增Redis缓存层实现任务状态实时存储与降级机制
2. 实现并发拉取+批量写入的高性能入库流程
3. 添加任务管理HTTP接口与路由
4. 重构行情控制器使用共享SQLite存储
5. 配套添加Docker Compose Redis启动配置
6. 前端新增任务管理面板支持操作与进度查看
2026-08-18 10:09:52 +08:00

156 lines
4.4 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.

// 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")
}