feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示

新增完整的异步任务管理体系:
1. 新增Redis缓存层实现任务状态实时存储与降级机制
2. 实现并发拉取+批量写入的高性能入库流程
3. 添加任务管理HTTP接口与路由
4. 重构行情控制器使用共享SQLite存储
5. 配套添加Docker Compose Redis启动配置
6. 前端新增任务管理面板支持操作与进度查看
This commit is contained in:
2026-08-18 10:09:52 +08:00
parent e28bfec957
commit 273fc90a95
18 changed files with 1682 additions and 80 deletions

View File

@@ -12,10 +12,12 @@ import (
"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"
@@ -29,23 +31,43 @@ func main() {
webDir := "web"
dbPath := defaultDBPath()
// 东财为主数据源(实时行情+历史K线),腾讯为历史K线备用源(东财失败时自动切换)
ds := fallback.New(eastmoney.NewClient(), tencent.NewClient())
svc := service.NewMarketService(ds)
mc := controller.NewMarketController(svc, dbPath)
// 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)
handler := router.New(mc, ac, webDir)
// 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,
@@ -73,6 +95,52 @@ func main() {
}
}
// 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 {